diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tests/agents_worktrees_notifications.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/tests/agents_worktrees_notifications.rs')
| -rw-r--r-- | src/client/shell/tests/agents_worktrees_notifications.rs | 1248 |
1 files changed, 1248 insertions, 0 deletions
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs new file mode 100644 index 00000000..0ee5aa3f --- /dev/null +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -0,0 +1,1248 @@ +use super::*; + +#[test] +fn mouse_hits_use_stable_workspace_tab_and_pane_ids() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("composed frame"); + + let workspace_down = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace_down.actions.is_empty()); + let workspace = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 2, + row: 2, + modifiers: KeyModifiers::empty(), + })]); + assert!(workspace.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &workspace.actions[..] else { + panic!("workspace click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + )); + + let pane = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 27, + row: 1, + modifiers: KeyModifiers::empty(), + })]); + assert!(pane.requests.is_empty()); + let [ClientShellAction::Endpoint { request, .. }] = &pane.actions[..] else { + panic!("pane click should use the endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); +} + +#[test] +fn collapsed_workspace_jitter_remains_a_click() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.sidebar_collapsed = true; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("collapsed sidebar"); + let workspace = state.hits.workspaces[0].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: workspace.x, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(state.chrome_drag.is_none()); + assert!(state.workspace_press.is_some()); + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: workspace.x + 1, + row: workspace.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn grouped_worktrees_render_parent_branch_and_indented_child() { + let config = ClientShellConfig::from_config(&Config::default()); + let mut state = ClientShellState::new(config); + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + snapshot.workspaces.push(ClientShellWorkspace { + workspace_id: "ws_2".into(), + active_tab_id: "tab_ws2".into(), + new_workspace_cwd: "/repo/feature".into(), + number: 2, + label: "repo-feature".into(), + custom_label: false, + branch: Some("worktree/feature".into()), + git_ahead_behind: None, + tokens: Vec::new(), + worktree: Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }), + focused: false, + agent_status: AgentStatus::Idle, + }); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 20).expect("composed frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("main")); + assert!(text.contains("└─")); + assert!(text.contains("feature")); + + let mut replacement = (**state.snapshot.as_ref().expect("snapshot")).clone(); + replacement.revision = 2; + replacement.focused_workspace_id = Some("ws_2".into()); + replacement.workspaces[0].focused = false; + replacement.workspaces[1].focused = true; + replacement.workspaces[1].agent_status = AgentStatus::Blocked; + let mut replacement_surface = surface(); + replacement_surface.projection_revision = 2; + state.collapsed_groups.insert("repo".into()); + state.set_snapshot(Box::new(replacement)); + state.set_pane_surface(replacement_surface); + let collapsed = state.compose(106, 20).expect("collapsed worktree group"); + let parent = state.hits.workspaces[0].rect; + let status_cell = usize::from(parent.y) * usize::from(collapsed.width) + + usize::from(parent.x.saturating_add(1)); + assert_eq!( + collapsed.cells[status_cell].fg, + crate::protocol::color_to_u32(state.config.palette.red) + ); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextWorkspace), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_1" + ) + )); +} + +#[test] +fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { + let mut projected = snapshot(); + for index in 2..=3 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("three workspaces"); + let first = state.hits.workspaces[0].rect; + let third = state.hits.workspaces[2].rect; + + let down = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x + 2, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(down.actions.is_empty()); + let drag = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(drag.repaint); + assert!(matches!( + state.chrome_drag, + Some(ClientChromeDrag::Workspace { + ref source_workspace_id, + target: Some((None, _)), + }) if source_workspace_id == "ws_1" + )); + let frame = state.compose(106, 24).expect("workspace drop indicator"); + assert!(frame + .cells + .chunks(frame.width as usize) + .any(|row| row.iter().take(20).any(|cell| cell.symbol == "─"))); + + let release = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: third.x + 2, + row: third.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &release.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMove(params) + if params.workspace_id == "ws_1" && params.insert_index == 3 + ) + )); + + state.compose(106, 24).expect("workspaces after drag"); + let second = state.hits.workspaces[1].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: second.x + 2, + row: second.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &click.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + ) + )); +} + +#[test] +fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { + let mut projected = snapshot(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut child = projected.workspaces[0].clone(); + child.workspace_id = "ws_child".into(); + child.number = 2; + child.label = "feature".into(); + child.focused = false; + child.worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut other = projected.workspaces[0].clone(); + other.workspace_id = "ws_other".into(); + other.number = 3; + other.label = "other".into(); + other.focused = false; + other.worktree = None; + projected.workspaces.extend([child, other]); + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).expect("worktree workspaces"); + assert!(state.hits.workspaces[1].indented); + let parent = state.hits.workspaces[0].rect; + let child = state.hits.workspaces[1].rect; + let other = state.hits.workspaces[2].rect; + + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: parent.x + 2, + row: parent.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!( + &moved.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceMoveBlock(params) + if params.workspace_ids == ["ws_1", "ws_child"] + && params.before_workspace_id.is_none() + ) + )); + + state.compose(106, 24).expect("worktree child"); + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: child.x + 2, + row: child.y, + modifiers: KeyModifiers::empty(), + })]); + let dragging_child = + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: other.x + 2, + row: other.bottom(), + modifiers: KeyModifiers::empty(), + })]); + assert!(dragging_child.actions.is_empty()); + assert!(state.chrome_drag.is_none()); +} + +#[test] +fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() { + let mut initial = snapshot(); + let mut second = initial.panes[0].clone(); + second.pane_id = "pane_2".into(); + second.focused = false; + initial.panes.push(second); + initial.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: None, + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(initial.clone())); + + let mut cycle = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CyclePaneNext), + &mut cycle, + ); + let [ClientShellAction::Endpoint { request, .. }] = &cycle.actions[..] else { + panic!("pane cycle should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + let mut replacement = initial; + replacement.revision = 2; + replacement.focused_pane_id = Some("pane_2".into()); + replacement.panes[0].focused = false; + replacement.panes[1].focused = true; + state.set_snapshot(Box::new(replacement)); + let mut last = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::LastPane), + &mut last, + ); + let [ClientShellAction::Endpoint { request, .. }] = &last.actions[..] else { + panic!("last pane should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_1" + )); + + let mut agent = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(1)), + &mut agent, + ); + let [ClientShellAction::Endpoint { request, .. }] = &agent.actions[..] else { + panic!("agent focus should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); +} + +#[test] +fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi one".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("first title".into()), + terminal_title_stripped: Some("first".into()), + agent_status: AgentStatus::Done, + state_change_seq: 10, + state_labels: Vec::new(), + tokens: vec![("summary".into(), "review complete".into())], + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi two".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: Some("second title".into()), + terminal_title_stripped: Some("second".into()), + agent_status: AgentStatus::Blocked, + state_change_seq: 20, + state_labels: vec![("blocked".into(), "needs input".into())], + tokens: vec![("summary".into(), "waiting for Can".into())], + focused: false, + }, + ]; + let mut config = Config::default(); + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; + config.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; + config.ui.sidebar.agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + config.ui.sidebar.agents.rows_by_agent.insert( + "pi".into(), + vec![ + vec![ + crate::config::AgentSidebarToken::StateIcon, + crate::config::AgentSidebarToken::StateText, + ], + vec![ + crate::config::AgentSidebarToken::Agent, + crate::config::AgentSidebarToken::Custom("summary".into()), + ], + ], + ); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + + let frame = state.compose(106, 30).expect("agent sidebar frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("× needs input"), "frame: {text}"); + assert!(text.contains("pi two"), "frame: {text}"); + assert!(text.contains("waiting for"), "frame: {text}"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + + let first = state.hits.agents[0].0; + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: first.x, + row: first.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("agent row should focus through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + )); + + state.compose(106, 10).expect("short agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_2") + ); + let body = state.hits.agent_body; + state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::ScrollDown, + column: body.x, + row: body.y, + modifiers: KeyModifiers::empty(), + })]); + state + .compose(106, 10) + .expect("scrolled agent sidebar frame"); + assert_eq!( + state + .hits + .agents + .first() + .map(|(_, pane_id)| pane_id.as_str()), + Some("pane_1") + ); + + state.sidebar_collapsed = true; + let compact = state.compose(106, 30).expect("compact agent sidebar frame"); + let blocked = state + .hits + .agents + .iter() + .find(|(_, pane_id)| pane_id == "pane_2") + .expect("blocked compact agent") + .0; + let row_start = blocked.y as usize * compact.width as usize + blocked.x as usize; + assert_ne!(compact.cells[row_start].fg, compact.cells[row_start + 2].fg); + assert_eq!(compact.cells[row_start].bg, compact.cells[row_start + 2].bg); +} + +#[test] +fn active_agent_view_controls_sidebar_order_and_focus_indices() { + let mut projected = snapshot(); + let mut second_pane = projected.panes[0].clone(); + second_pane.pane_id = "pane_2".into(); + second_pane.focused = false; + projected.panes.push(second_pane.clone()); + let mut third_pane = second_pane; + third_pane.pane_id = "pane_3".into(); + projected.panes.push(third_pane); + projected.agents = vec![ + ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("first".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }, + ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("second".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 2, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ClientShellAgent { + pane_id: "pane_3".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("third".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Idle, + state_change_seq: 3, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }, + ]; + projected.agent_view_label = Some("review".into()); + projected.agent_order = vec!["pane_2".into(), "pane_3".into()]; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("filtered agent sidebar"); + assert_eq!( + state + .hits + .agents + .iter() + .map(|(_, pane_id)| pane_id.as_str()) + .collect::<Vec<_>>(), + vec!["pane_2", "pane_3"] + ); + assert_eq!(state.hits.agent_sort_toggle, Rect::default()); + + let mut focus = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(0)), + &mut focus, + ); + assert!(matches!( + &focus.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); + + let mut next = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextAgent), + &mut next, + ); + assert!(matches!( + &next.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2" + ) + )); +} + +#[test] +fn agent_sort_toggle_is_client_local_and_persists_per_endpoint() { + let path = std::env::temp_dir().join(format!( + "herdr-shell-agent-sort-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("pi".into()), + display_agent: None, + agent: Some("pi".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Working, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: true, + }); + let config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 30).expect("agent sidebar frame"); + let toggle = state.hits.agent_sort_toggle; + + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::empty(), + })]); + + assert_eq!( + state.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(click.actions.is_empty()); + let reloaded_config = + ClientShellConfig::from_config(&Config::default()).with_preferences_path(path.clone()); + let reloaded = ClientShellState::new(reloaded_config); + assert_eq!( + reloaded.config.agent_panel_sort, + crate::config::AgentPanelSortConfig::Priority + ); + assert!(reloaded.agent_panel_sort_manual); + std::fs::remove_file(path).expect("remove agent sort preferences"); +} + +#[test] +fn workspace_actions_preserve_selected_target_and_client_confirmation() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_2".into()); + + let rename = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('w'), + KeyModifiers::SHIFT, + ))]); + assert!(rename.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + target: ClientRenameTarget::Workspace { workspace_id }, + .. + })) if workspace_id == "ws_2" + )); + assert!(state.handle_input_bytes(&[0x15]).actions.is_empty()); + assert!(state.handle_input_bytes(b"renamed").actions.is_empty()); + let save = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &save.actions[..] else { + panic!("workspace rename should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceRename(params) + if params.workspace_id == "ws_2" && params.label == "renamed" + )); + + state.navigate_workspace_id = Some("ws_2".into()); + let mut close = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::CloseWorkspace), + &mut close, + ); + assert!(close.actions.is_empty()); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { + workspace_id, + .. + })) if workspace_id == "ws_2" + )); + let confirm = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &confirm.actions[..] else { + panic!("workspace confirmation should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_2" && params.close_group + )); +} + +#[test] +fn desktop_workspace_navigation_reveals_overflowing_selection() { + let mut projected = snapshot(); + for index in 2..=8 { + let mut workspace = projected.workspaces[0].clone(); + workspace.workspace_id = format!("ws_{index}"); + workspace.number = index; + workspace.label = format!("workspace-{index}"); + workspace.focused = false; + projected.workspaces.push(workspace); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.mode = ClientShellMode::Navigate; + state.navigate_workspace_id = Some("ws_1".into()); + state.compose(106, 12).expect("overflowing sidebar"); + + for _ in 0..6 { + state.handle_input_bytes(b"\x1b[B"); + state.compose(106, 12).expect("revealed workspace"); + let selected = state.navigate_workspace_id.as_deref().expect("selection"); + assert!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == selected), + "{selected} should remain visible" + ); + } +} + +#[test] +fn named_workspace_overlay_targets_projected_source_workspace() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = true; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + let mut open = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorkspace), + &mut open, + ); + assert!(matches!( + state.overlay.as_ref(), + Some(ClientShellOverlay::Rename(ClientRenameOverlay { + input: value, + target: ClientRenameTarget::NewWorkspace { + source_workspace_id, + .. + }, + .. + })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1") + )); + let create = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else { + panic!("named workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceCreate(params) + if params.source_workspace_id.as_deref() == Some("ws_1") + && params.cwd.as_deref() == Some("/repo") + && params.label.is_none() + )); +} + +#[test] +fn navigate_mode_selects_workspace_locally_then_focuses_by_stable_id() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + + assert!(state.handle_input_bytes(&[0x02]).actions.is_empty()); + let enter_navigate = state.handle_input_bytes(b"w"); + assert!(enter_navigate.repaint); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let invalid = state.handle_input_bytes(b"9"); + assert!(invalid.actions.is_empty()); + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_1")); + + let move_selection = state.handle_input_bytes(b"\x1b[B"); + assert!(move_selection.actions.is_empty()); + assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_2")); + let frame = state.compose(106, 20).expect("navigate frame"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("second")); + assert!(text.contains("NAVIGATE")); + + let focus = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &focus.actions[..] else { + panic!("selected workspace should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + )); + assert_eq!(state.mode, ClientShellMode::Terminal); +} + +#[test] +fn worktree_create_previews_the_endpoint_owned_checkout_path() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("new worktree should prepare through worktree.list"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeList(params) + if params.workspace_id.as_deref() == Some("ws_1") + )); + let request_id = request.id.clone(); + assert!( + state + .handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))) + .0 + ); + let frame = state.compose(106, 30).expect("new worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("new worktree")); + assert!(text.contains("create and open")); + assert!(frame.cursor.as_ref().is_some_and(|cursor| cursor.visible)); + + assert!(state + .handle_input_bytes(b"feature/client-shell") + .actions + .is_empty()); + assert!(matches!( + &state.overlay, + Some(ClientShellOverlay::WorktreeCreate(create)) + if create.checkout_path + == "/tmp/herdr-worktrees/repo/feature-client-shell" + )); + let submit = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else { + panic!("worktree create should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeCreate(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.branch.as_deref() == Some("feature/client-shell") + && params.path.is_none() + && params.focus + )); +} + +#[test] +fn worktree_open_filters_and_clicks_a_stable_public_entry() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("open worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result("boot-1", &request_id, Ok(worktree_list_result(None))); + let frame = state.compose(106, 30).expect("open worktree modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("feature")); + let row = state.hits.worktree_rows[0].0; + let open = state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: row.x + 2, + row: row.y, + modifiers: KeyModifiers::empty(), + })]); + let [ClientShellAction::Endpoint { request, .. }] = &open.actions[..] else { + panic!("worktree row should open through endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeOpen(params) + if params.workspace_id.as_deref() == Some("ws_1") + && params.path.as_deref() == Some("/repo-feature") + && params.focus + )); +} + +#[test] +fn worktree_remove_escalates_dirty_failure_to_force_confirmation() { + let mut snapshot = snapshot(); + snapshot.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo-key".into(), + label: "repo".into(), + is_linked_worktree: true, + }); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + let mut prepare = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::RemoveWorktree), + &mut prepare, + ); + let [ClientShellAction::Endpoint { request, .. }] = &prepare.actions[..] else { + panic!("remove worktree should prepare through worktree.list"); + }; + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Ok(worktree_list_result(Some("ws_1"))), + ); + let remove = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &remove.actions[..] else { + panic!("worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && !params.force + )); + let request_id = request.id.clone(); + state.handle_endpoint_result( + "boot-1", + &request_id, + Err(ClientShellEndpointError { + code: Some("dirty_worktree_requires_force".into()), + message: "dirty worktree".into(), + }), + ); + let frame = state.compose(106, 30).expect("force remove modal"); + let text = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(text.contains("delete anyway")); + assert!(text.contains("permanently deleted")); + let force = state.handle_input_bytes(b"\r"); + let [ClientShellAction::Endpoint { request, .. }] = &force.actions[..] else { + panic!("forced worktree remove should use endpoint API"); + }; + assert!(matches!( + &request.method, + crate::api::schema::Method::WorktreeRemove(params) + if params.workspace_id == "ws_1" && params.force + )); +} + +#[test] +fn semantic_notifications_use_client_policy_and_stable_navigation_targets() { + let mut config = ClientShellConfig::from_config(&Config::default()); + config.toast_delivery = crate::config::ToastDelivery::Herdr; + config.toast_delay_seconds = 0; + let mut state = ClientShellState::new(config); + let mut projected = snapshot(); + projected.agents.push(ClientShellAgent { + pane_id: "pane_2".into(), + workspace_id: "ws_2".into(), + tab_id: "tab_2".into(), + name: None, + display_agent: Some("codex".into()), + agent: Some("codex".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Blocked, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: Vec::new(), + focused: false, + }); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let now = std::time::Instant::now(); + let (effects, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: Some("other · 2".into()), + sound: Some(SemanticNotificationSound::Request), + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(matches!( + effects.as_slice(), + [ClientShellNotificationEffect::Sound { + sound: crate::sound::Sound::Request, + .. + }] + )); + let frame = state.compose(100, 28).expect("notification frame"); + let rendered = frame + .cells + .chunks(frame.width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(rendered.contains("codex needs attention")); + let hit = state.hits.notification_toast; + let click = || { + RawInputEvent::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: hit.x, + row: hit.y, + modifiers: KeyModifiers::empty(), + }) + }; + state.mode = ClientShellMode::Navigate; + let ignored = state.handle_raw_events(vec![click()]); + assert!(ignored.actions.is_empty()); + assert!(state.visible_notification.is_some()); + + state.mode = ClientShellMode::Terminal; + let outcome = state.handle_raw_events(vec![click()]); + assert!(outcome.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "codex needs attention".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + let mut keybind = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::OpenNotificationTarget), + &mut keybind, + ); + assert!(keybind.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::PaneFocus(params) + if params.pane_id == "pane_2" + ) + ))); + assert!(state.visible_notification.is_none()); + + state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "first".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(state.visible_notification.is_some()); + state.config.toast_delay_seconds = 1; + let (_, repaint) = state.receive_notification( + SemanticNotification { + kind: SemanticNotificationKind::NeedsAttention, + title: "replacement".into(), + body: None, + sound: None, + agent: Some("codex".into()), + workspace_id: Some("ws_2".into()), + tab_id: Some("tab_2".into()), + pane_id: Some("pane_2".into()), + position: None, + }, + now, + ); + assert!(repaint); + assert!(state.visible_notification.is_none()); + assert_eq!(state.pending_notifications.len(), 1); +} |
