fix: keep dashboard row selection explicit

This commit is contained in:
Keisuke Hirata 2026-06-23 16:13:37 +09:00
parent 4cda83b748
commit 5c242d9620
No known key found for this signature in database
2 changed files with 184 additions and 77 deletions

View File

@ -1266,8 +1266,7 @@ impl DashboardApp {
.list
.selected_name
.clone()
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
.filter(|name| list.entries.iter().any(|entry| entry.name == *name));
let panel = build_workspace_panel(&current_workspace_root(), &list);
self.apply_reloaded_snapshot(DashboardSnapshot { list, panel });
}
@ -1276,25 +1275,17 @@ impl DashboardApp {
self.apply_companion_lifecycle_memory(&mut snapshot.panel);
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
let previous_selected_pod = self.list.selected_name.clone();
snapshot.list.selected_name = previous_selected_pod
.filter(|name| {
snapshot.list.selected_name = previous_selected_pod.filter(|name| {
snapshot
.list
.entries
.iter()
.any(|entry| entry.name == *name)
})
.or_else(|| {
snapshot
.list
.entries
.first()
.map(|entry| entry.name.clone())
});
let previous_row = self.selected_row.clone();
self.list = snapshot.list;
self.panel = snapshot.panel;
self.selected_row = previous_row.filter(|key| self.panel.row(key).is_some());
self.selected_row = previous_row;
self.ensure_selection_visible();
self.ensure_composer_target_available();
self.refresh_orchestrator_work_set();
@ -1444,12 +1435,14 @@ impl DashboardApp {
self.list.selected_name = None;
return;
}
let selected_pos = self
let next_pos = match self
.selected_row
.as_ref()
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
.unwrap_or(0);
let next_pos = (selected_pos + 1).min(visible.len() - 1);
{
Some(selected_pos) => (selected_pos + 1).min(visible.len() - 1),
None => 0,
};
self.select_panel_key(visible[next_pos].clone());
}
@ -1460,12 +1453,15 @@ impl DashboardApp {
self.list.selected_name = None;
return;
}
let selected_pos = self
let prev_pos = match self
.selected_row
.as_ref()
.and_then(|key| visible.iter().position(|visible_key| visible_key == key))
.unwrap_or(0);
self.select_panel_key(visible[selected_pos.saturating_sub(1)].clone());
{
Some(selected_pos) => selected_pos.saturating_sub(1),
None => 0,
};
self.select_panel_key(visible[prev_pos].clone());
}
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
@ -1623,55 +1619,32 @@ impl DashboardApp {
fn ensure_selection_visible(&mut self) {
let visible = visible_panel_keys(&self.panel, &self.list);
if visible.is_empty() {
self.selected_row = None;
let Some(selected_key) = self.selected_row.as_ref() else {
self.list.selected_name = None;
return;
}
let selected_visible = self
.selected_row
.as_ref()
.is_some_and(|key| visible.iter().any(|visible_key| visible_key == key));
if !selected_visible {
let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action());
let orchestrator_pod_name = self
.panel
.header
.orchestrator
.as_ref()
.map(|state| state.pod_name.as_str());
if !has_action_rows {
if let Some(selected_name) = self.list.selected_name.as_ref() {
if Some(selected_name.as_str()) != orchestrator_pod_name {
let key = PanelRowKey::Pod(selected_name.clone());
if visible.iter().any(|visible_key| visible_key == &key) {
self.select_panel_key(key);
return;
}
}
}
if let Some(key) = visible.iter().find(|key| match key {
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
};
if visible
.iter()
.any(|visible_key| visible_key == selected_key)
{
match selected_key {
PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => true,
}) {
self.select_panel_key(key.clone());
return;
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
}
} else {
self.selected_row = None;
self.list.selected_name = None;
return;
}
self.select_panel_key(visible[0].clone());
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() {
self.list.selected_name = Some(name.clone());
}
}
fn select_panel_key(&mut self, key: PanelRowKey) {
if let PanelRowKey::Pod(name) = &key {
self.list.selected_name = Some(name.clone());
match &key {
PanelRowKey::Pod(name) => self.list.selected_name = Some(name.clone()),
PanelRowKey::Ticket(_)
| PanelRowKey::InvalidTicket(_)
| PanelRowKey::TicketIntakePod { .. } => self.list.selected_name = None,
}
#[cfg(feature = "e2e-test")]
let selected_key = key.clone();

View File

@ -481,6 +481,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
"ready",
));
let mut app = app_with_panel(empty_test_list(), panel);
app.select_next();
app.cycle_composer_target();
app.input.insert_str("clarify expected behavior");
@ -938,6 +939,111 @@ fn no_ticket_selection_keeps_enter_pod_centric() {
assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected."));
}
#[test]
fn workspace_panel_initial_display_does_not_auto_select_visible_rows() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Ready",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let app = app_with_panel(
PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info("alpha", PodStatus::Idle)],
None,
10,
),
panel,
);
assert!(visible_panel_keys(&app.panel, &app.list).len() > 1);
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
}
#[test]
fn workspace_panel_clear_selection_survives_reload_and_keeps_draft() {
let mut app = test_app(vec![live_info_with_updated_at(
"alpha",
PodStatus::Idle,
10,
)]);
app.select_next();
assert_eq!(
app.selected_row,
Some(PanelRowKey::Pod("alpha".to_string()))
);
app.input.insert_str("draft survives");
assert!(matches!(
app.handle_key(key(KeyCode::Esc)),
DashboardAction::None
));
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
app.apply_reloaded_list(PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info_with_updated_at("alpha", PodStatus::Running, 20)],
None,
10,
));
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
assert_eq!(input_text(&app), "draft survives");
}
#[test]
fn workspace_panel_no_selection_ticket_intake_submit_uses_global_intake() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
panel.rows.push(panel_test_ticket_row(
"TICKET-1",
"Ready",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let mut app = app_with_panel(empty_test_list(), panel);
app.cycle_composer_target();
app.input.insert_str("new planning request");
let request = match app.handle_key(key(KeyCode::Enter)) {
DashboardAction::LaunchIntake(request) => request,
_ => panic!("no selection should launch global Intake"),
};
assert!(request.context.ticket.is_none());
assert_eq!(
request.context.user_instruction.as_deref(),
Some("new planning request")
);
}
#[test]
fn workspace_panel_keyboard_navigation_explicitly_creates_selection() {
let mut app = test_app(vec![
live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle),
]);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(
app.selected_row,
Some(PanelRowKey::Pod("alpha".to_string()))
);
app.select_next();
assert_eq!(app.selected_row, Some(PanelRowKey::Pod("beta".to_string())));
}
#[test]
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
let temp = TempDir::new().unwrap();
@ -960,6 +1066,8 @@ fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
);
let panel = build_workspace_panel(temp.path(), &list);
let mut app = app_with_panel(list, panel);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
@ -1176,6 +1284,7 @@ fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
10,
);
let mut app = app_with_panel(list, panel);
app.select_next();
app.input.insert_str("draft to companion");
assert_eq!(
@ -1201,7 +1310,7 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
live_info("alpha", PodStatus::Idle),
live_info("beta", PodStatus::Idle),
]);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(app.selected_row.is_none());
for c in ['j', 'k', 'o', 'r'] {
assert!(matches!(
@ -1211,14 +1320,14 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
}
assert_eq!(input_text(&app), "jkor");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(app.selected_row.is_none());
assert!(matches!(
app.handle_key(key(KeyCode::Down)),
DashboardAction::None
));
assert_eq!(input_text(&app), "jkor");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(app.selected_row.is_none());
app.input.clear();
assert!(matches!(
@ -1226,6 +1335,12 @@ fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank(
DashboardAction::None
));
assert_eq!(input_text(&app), "");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(matches!(
app.handle_key(key(KeyCode::Down)),
DashboardAction::None
));
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
assert!(matches!(
@ -1248,7 +1363,7 @@ fn dashboard_selection_changes_preserve_composer_contents() {
app.select_next();
assert_eq!(input_text(&app), before);
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
}
#[test]
@ -1258,7 +1373,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
live_info_with_updated_at("beta", PodStatus::Idle, 20),
]);
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
app.input.insert_str("draft survives polling");
app.notice = Some("keep this notice".to_string());
let refreshed = PodList::from_sources(
@ -1275,7 +1390,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
app.apply_reloaded_list(refreshed);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
assert_eq!(
app.list
.selected_entry()
@ -1284,7 +1399,7 @@ fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
.as_ref()
.unwrap()
.status,
Some(PodStatus::Running)
Some(PodStatus::Idle)
);
assert_eq!(input_text(&app), "draft survives polling");
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
@ -1296,6 +1411,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
live_info_with_updated_at("beta", PodStatus::Running, 20),
]);
app.select_next();
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
let refreshed = PodList::from_sources(
PodVisibilitySource::ResumePicker,
@ -1307,7 +1424,8 @@ fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
app.apply_reloaded_list(refreshed);
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
}
@ -1526,7 +1644,8 @@ async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() {
#[test]
fn dashboard_idle_live_selected_target_is_open_eligible() {
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
app.select_next();
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
}
@ -1855,6 +1974,9 @@ fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
app.selected_row = None;
app.ensure_selection_visible();
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "running");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
@ -1933,6 +2055,8 @@ fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows(
);
let mut app = app_with_list(list);
assert!(app.selected_row.is_none());
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "running");
@ -1969,7 +2093,7 @@ fn dashboard_selection_does_not_default_to_orchestrator_only_row() {
}
#[test]
fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
fn dashboard_selection_has_no_default_when_orchestrator_pod_exists() {
let list = PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
@ -1988,7 +2112,8 @@ fn dashboard_selection_prefers_non_orchestrator_pod_by_default() {
));
let app = app_with_panel(list, panel);
assert_eq!(app.list.selected_entry().unwrap().name, "worker");
assert!(app.selected_row.is_none());
assert!(app.list.selected_name.is_none());
}
#[test]
@ -2139,6 +2264,7 @@ fn dashboard_companion_finish_success_clears_composer() {
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.input.insert_str("draft survives open");
app.select_next();
let request = app.prepare_open().unwrap();
@ -2207,6 +2333,7 @@ fn dashboard_nested_console_success_continues_without_dropping_state() {
live_info("beta", PodStatus::Idle),
]);
app.select_next();
app.select_next();
app.input.insert_str("keep this draft");
app.panel_diagnostic = Some(PanelDiagnostic {
title: "diagnostic stays".to_string(),
@ -2245,6 +2372,7 @@ fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state
live_info("beta", PodStatus::Idle),
]);
app.select_next();
app.select_next();
app.input.insert_str("keep this draft");
app.panel_diagnostic = Some(PanelDiagnostic {
title: "diagnostic stays".to_string(),
@ -2299,6 +2427,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
live.reachable = false;
live.status = None;
let mut app = test_app(vec![live]);
app.select_next();
assert!(app.prepare_open().is_none());
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
@ -2307,6 +2436,7 @@ fn dashboard_open_disabled_target_stays_in_dashboard() {
#[test]
fn dashboard_empty_enter_uses_open_action() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
assert!(matches!(
app.handle_key(key(KeyCode::Enter)),
@ -2331,6 +2461,7 @@ fn dashboard_empty_enter_uses_open_action() {
#[test]
fn dashboard_whitespace_only_enter_uses_open_action() {
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
app.input.insert_str(" \n\t");
assert!(matches!(
@ -2407,6 +2538,7 @@ fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch()
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
panel,
);
app.select_next();
let selected_before = app.selected_row.clone();
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
@ -2450,6 +2582,7 @@ fn dashboard_composer_shared_word_motion_and_delete_keys() {
#[test]
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
app.select_next();
app.input.insert_str("draft message");
assert!(app.selected_row.is_some());
@ -2793,6 +2926,7 @@ fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
#[test]
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
let mut app = test_app(vec![unreachable_live_info("unreachable")]);
app.select_next();
assert!(matches!(
app.handle_key(key(KeyCode::Enter)),
DashboardAction::Open