aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell/tests')
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs8
-rw-r--r--src/client/shell/tests/copy.rs10
-rw-r--r--src/client/shell/tests/endpoint_requests.rs298
-rw-r--r--src/client/shell/tests/endpoints.rs1205
-rw-r--r--src/client/shell/tests/keybindings_settings.rs5
-rw-r--r--src/client/shell/tests/mobile.rs95
-rw-r--r--src/client/shell/tests/mod.rs4
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs2
-rw-r--r--src/client/shell/tests/startup_overlays.rs1
9 files changed, 1613 insertions, 15 deletions
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index 2ccfe77b..10f97118 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -966,9 +966,7 @@ fn worktree_create_previews_the_endpoint_owned_checkout_path() {
&state.overlay,
Some(ClientShellOverlay::WorktreeCreate(create))
if create.checkout_path
- == std::path::Path::new("/tmp/herdr-worktrees")
- .join("repo")
- .join("feature-client-shell").to_string_lossy()
+ == "/tmp/herdr-worktrees/repo/feature-client-shell"
));
let submit = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = &submit.actions[..] else {
@@ -1154,6 +1152,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
state.set_pane_surface(surface());
let now = std::time::Instant::now();
let (effects, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1215,6 +1214,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "codex needs attention".into(),
@@ -1245,6 +1245,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_none());
state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "first".into(),
@@ -1261,6 +1262,7 @@ fn semantic_notifications_use_client_policy_and_stable_navigation_targets() {
assert!(state.visible_notification.is_some());
state.config.toast_delay_seconds = 1;
let (_, repaint) = state.receive_notification(
+ &ClientEndpointId::Local,
SemanticNotification {
kind: SemanticNotificationKind::NeedsAttention,
title: "replacement".into(),
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index e8555db6..7d71a981 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -766,22 +766,22 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
state.handle_input_bytes(b"\x1b");
state.handle_input_bytes(b"a");
state.compose(106, 30).expect("navigator rows");
- let pane_index = {
- let snapshot = state.snapshot.as_deref().expect("snapshot");
+ let pane_target = {
let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
else {
panic!("expected navigator");
};
- render::client_navigator_rows(snapshot, navigator)
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
.iter()
- .position(|row| matches!(row.target, ClientNavigatorTarget::Pane(_)))
+ .find(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .map(|row| row.target.clone())
.expect("pane row")
};
let pane_rect = state
.hits
.navigator_rows
.iter()
- .find(|(_, index)| *index == pane_index)
+ .find(|(_, target)| *target == pane_target)
.map(|(rect, _)| *rect)
.expect("visible pane row");
let select =
diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs
new file mode 100644
index 00000000..8149b530
--- /dev/null
+++ b/src/client/shell/tests/endpoint_requests.rs
@@ -0,0 +1,298 @@
+use super::*;
+use crate::client::endpoint::{ClientEndpointId, ClientEndpointStatus};
+
+fn pending_popup() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let binding = crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::prefix("t"),
+ label: "prefix+t".into(),
+ command: "popup-command".into(),
+ action: crate::config::CustomCommandAction::Popup,
+ description: None,
+ width: None,
+ height: None,
+ };
+ let mut projection = snapshot();
+ projection
+ .commands
+ .push(crate::protocol::ClientShellCommand {
+ command_id: "cmd_popup".into(),
+ binding_label: binding.label.clone(),
+ binding_labels: binding.bindings.labels(),
+ action: crate::protocol::ClientShellCommandAction::Popup,
+ description: None,
+ });
+ state.set_snapshot(Box::new(projection));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(crate::input::KeybindMatch::Command(binding), &mut outcome);
+ assert!(state.popup_pending);
+ (state, outcome.actions)
+}
+
+fn pending_worktree() -> (ClientShellState, Vec<ClientShellAction>) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut outcome = ClientShellInput::default();
+ state.record_binding(
+ crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewWorktree),
+ &mut outcome,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else {
+ panic!("expected worktree preparation");
+ };
+ state.handle_endpoint_result("boot-1", &request.id, Ok(worktree_list_result(None)));
+ state.handle_input_bytes(b"feature/reconnect");
+ let outcome = state.handle_input_bytes(b"\r");
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if create.creating
+ ));
+ (state, outcome.actions)
+}
+
+fn request_id(actions: &[ClientShellAction]) -> &str {
+ let [ClientShellAction::Endpoint { request, .. }] = actions else {
+ panic!("expected one endpoint request");
+ };
+ &request.id
+}
+
+#[test]
+fn cancelling_popup_request_unblocks_input_and_ignores_late_success() {
+ let (mut state, actions) = pending_popup();
+ let id = request_id(&actions);
+ assert!(state.cancel_endpoint_request(id));
+ assert!(!state.popup_pending);
+ assert!(state.pending_requests.is_empty());
+ assert!(state
+ .handle_endpoint_result("boot-1", id, Ok(crate::api::schema::ResponseResult::Ok {}))
+ .1
+ .is_empty());
+ assert!(!state.popup_pending);
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+#[test]
+fn disconnect_cancels_worktree_dialog_before_same_server_reconnect() {
+ let (mut state, _) = pending_worktree();
+ state.mark_endpoint_disconnected(&ClientEndpointId::Local);
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&ClientEndpointId::Local, Box::new(snapshot()));
+ assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
+ state.set_pane_surface(surface());
+ state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Esc,
+ KeyModifiers::NONE,
+ ))]);
+ assert!(state.overlay.is_none());
+ assert!(!state.handle_input_bytes(b"x").requests.is_empty());
+}
+
+struct TestTransport {
+ fail: bool,
+}
+
+impl crate::client::endpoint::EndpointTransport for TestTransport {
+ fn send(&mut self, _: &ClientMessage) -> std::io::Result<()> {
+ if self.fail {
+ Err(std::io::ErrorKind::BrokenPipe.into())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+#[test]
+fn dispatcher_cancels_worktree_requests_on_frozen_surface_or_failed_send() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ for fail_send in [false, true] {
+ let (mut state, actions) = pending_worktree();
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: fail_send },
+ 1,
+ EndpointNegotiation::default(),
+ );
+ endpoints.set_surface_active(&ClientEndpointId::Local, fail_send);
+ let mut commands = EndpointCommands::default();
+ let (tx, _rx) = tokio::sync::mpsc::channel(16);
+ let (replay, repaint) = crate::client::shell_runtime::dispatch_client_shell_actions(
+ actions,
+ &mut commands,
+ &mut endpoints,
+ Some(&mut state),
+ &mut Vec::new(),
+ &tx,
+ )
+ .unwrap();
+ assert!(repaint);
+ assert!(replay.is_empty());
+ assert!(state.pending_requests.is_empty());
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating
+ ));
+ assert!(state
+ .visible_endpoint_notice
+ .as_ref()
+ .is_some_and(|notice| { notice.title == "Action interrupted" }));
+ assert!(commands.disconnect(&ClientEndpointId::Local).is_empty());
+ }
+}
+
+#[test]
+fn stale_queued_request_is_cancelled_without_blocking_the_current_generation() {
+ use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry};
+ use crate::client::endpoint_commands::EndpointCommands;
+
+ let (mut state, actions) = pending_popup();
+ let stale_id = request_id(&actions).to_owned();
+ let current = state.focus_endpoint_target(ClientEndpointFocusTarget::Workspace("ws_1".into()));
+ let current_id = request_id(&current).to_owned();
+ let mut commands = EndpointCommands::default();
+ for (generation, actions) in [(1, actions), (2, current)] {
+ for action in actions {
+ let ClientShellAction::Endpoint {
+ endpoint_id,
+ boot_id,
+ request,
+ } = action
+ else {
+ panic!("expected endpoint request");
+ };
+ commands.enqueue(endpoint_id, generation, boot_id, request);
+ }
+ }
+ let mut endpoints = EndpointRegistry::new(
+ TestTransport { fail: false },
+ 2,
+ EndpointNegotiation::default(),
+ );
+ let cancelled = commands.send_next(&ClientEndpointId::Local, &mut endpoints);
+ assert_eq!(cancelled, vec![stale_id.clone()]);
+ state.cancel_endpoint_request(&stale_id);
+ assert!(!state.popup_pending);
+ assert!(!commands.accepts_response(&ClientEndpointId::Local, 1, "boot-1", &stale_id));
+ assert!(commands.accepts_response(&ClientEndpointId::Local, 2, "boot-1", &current_id));
+ assert!(state.pending_requests.contains_key(&current_id));
+}
+
+#[test]
+fn cancelled_integration_install_does_not_queue_a_refresh() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.open_settings_overlay();
+ let Some(ClientShellOverlay::Settings(settings)) = state.overlay.as_mut() else {
+ panic!("settings overlay");
+ };
+ settings.installing_integrations = true;
+ state.pending_integration_installs = 1;
+ let mut outcome = ClientShellInput::default();
+ assert!(state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::IntegrationInstall(
+ crate::api::schema::IntegrationInstallParams {
+ target: crate::api::schema::IntegrationTarget::Pi,
+ }
+ ),
+ PendingEndpointKind::IntegrationInstall,
+ &mut outcome,
+ ));
+ assert!(state.cancel_endpoint_request(request_id(&outcome.actions)));
+ assert!(state.pending_requests.is_empty());
+ assert_eq!(state.pending_integration_installs, 0);
+ assert!(matches!(
+ &state.overlay,
+ Some(ClientShellOverlay::Settings(settings))
+ if !settings.installing_integrations && !settings.loading_integrations
+ ));
+}
+
+#[test]
+fn cancelled_selection_does_not_replay_its_fallback_key() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.selection = Some(crate::selection::Selection::absolute_range(
+ "pane_1".into(),
+ (0, 0),
+ (0, 2),
+ ));
+ let mut outcome = ClientShellInput::default();
+ state.request_selection_copy_with_fallback(
+ &mut outcome,
+ Some(crate::input::TerminalKey::new(
+ KeyCode::Char('c'),
+ KeyModifiers::CONTROL,
+ )),
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.pending_requests.is_empty());
+}
+
+#[test]
+fn cancelled_link_activation_does_not_replay_mouse_input() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ state.compose(100, 28).unwrap();
+ let pane_id = state.hits.panes[0].pane_id.clone();
+ let inner_rect = state.hits.panes[0].inner_rect;
+ let mut outcome = ClientShellInput::default();
+ state.push_endpoint_method_with_kind(
+ crate::api::schema::Method::PaneLinkActivate(crate::api::schema::PaneLinkActivateParams {
+ pane_id: pane_id.clone(),
+ viewport_row: 0,
+ col: 0,
+ content_revision: None,
+ offset_from_bottom: None,
+ }),
+ PendingEndpointKind::PaneLinkActivate {
+ pane_id,
+ inner_rect,
+ fallback_events: vec![MouseEvent {
+ kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ column: inner_rect.x,
+ row: inner_rect.y,
+ modifiers: KeyModifiers::NONE,
+ }],
+ },
+ &mut outcome,
+ );
+ let (_, actions) = state.handle_endpoint_result(
+ "boot-1",
+ request_id(&outcome.actions),
+ Err(ClientShellEndpointError {
+ code: Some("endpoint_cancelled".into()),
+ message: "cancelled".into(),
+ }),
+ );
+ assert!(actions.is_empty());
+ assert!(state.url_click_consumes_until_up);
+}
+
+#[test]
+fn another_machine_disconnect_does_not_cancel_active_popup() {
+ let (mut state, _) = pending_popup();
+ let remote = ClientEndpointId::Ssh(
+ crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ );
+ state.mark_endpoint_disconnected(&remote);
+ assert!(state.popup_pending);
+ assert_eq!(state.pending_requests.len(), 1);
+}
diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs
new file mode 100644
index 00000000..52c71923
--- /dev/null
+++ b/src/client/shell/tests/endpoints.rs
@@ -0,0 +1,1205 @@
+use super::*;
+use crate::client::endpoint::{
+ ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint,
+};
+use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
+
+fn remote_profile() -> SavedSshEndpoint {
+ SavedSshEndpoint {
+ id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "dev@build.example".into(),
+ session: "agents".into(),
+ enabled: true,
+ }
+}
+
+fn agent(
+ name: &str,
+ status: crate::api::schema::AgentStatus,
+ state_change_seq: u64,
+) -> ClientShellAgent {
+ ClientShellAgent {
+ pane_id: "pane_1".into(),
+ workspace_id: "ws_1".into(),
+ tab_id: "tab_1".into(),
+ name: Some(name.into()),
+ display_agent: None,
+ agent: Some("pi".into()),
+ title: None,
+ terminal_title: None,
+ terminal_title_stripped: None,
+ agent_status: status,
+ state_change_seq,
+ state_labels: Vec::new(),
+ tokens: Vec::new(),
+ focused: true,
+ }
+}
+
+fn state_with_remote() -> (ClientShellState, ClientEndpointId) {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.workspaces[0].label = "remote-workspace".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ (state, endpoint_id)
+}
+
+#[test]
+fn switching_machines_from_copy_mode_restores_terminal_input() {
+ let (mut state, remote) = state_with_remote();
+ let mut local_surface = surface();
+ local_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 20,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(local_surface);
+ state.compose(100, 28).unwrap();
+ assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ assert_eq!(state.mode, ClientShellMode::Copy);
+
+ assert!(state.activate_endpoint_projection(&remote));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.compose(100, 28).unwrap();
+
+ assert!(state.copy_mode.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ let input = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Char('x'),
+ KeyModifiers::NONE,
+ ))]);
+ assert!(matches!(
+ input.requests.as_slice(),
+ [ClientMessage::ClientShellPaneInput { pane_id, events }]
+ if pane_id == "pane_1" && events.len() == 1
+ ));
+}
+
+#[test]
+fn live_catalog_rename_preserves_snapshot_and_disable_reenable_clears_it() {
+ let (mut state, remote) = state_with_remote();
+ let mut profile = remote_profile();
+ profile.label = "Renamed".into();
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(state.endpoint_label(&remote), "Renamed");
+ assert!(state.endpoint_is_online(&remote));
+ assert_eq!(state.endpoint_boot_id(&remote), Some("remote-boot"));
+ profile.enabled = false;
+ state.set_endpoint_catalog(&[profile.clone()]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Disabled)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+ profile.enabled = true;
+ state.set_endpoint_catalog(&[profile]);
+ assert_eq!(
+ state.endpoint_status(&remote),
+ Some(ClientEndpointStatus::Connecting)
+ );
+ assert!(!state.endpoint_has_snapshot(&remote));
+}
+
+#[test]
+fn live_catalog_active_removal_does_not_retain_remote_projection_or_input() {
+ let (mut state, remote) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&remote));
+ state.set_pane_surface(surface());
+ state.mode = ClientShellMode::Prefix;
+ state.overlay = Some(ClientShellOverlay::Onboarding);
+ state.select_unavailable_local();
+ state.retire_endpoint(&remote);
+ state.set_endpoint_catalog(&[]);
+ assert!(state.endpoint_is_active(&ClientEndpointId::Local));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ assert!(state.pending_pane_surface.is_none());
+ assert!(state.overlay.is_none());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.endpoint_has_snapshot(&ClientEndpointId::Local));
+ let frame = state.compose(100, 30).unwrap();
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let text = buffer
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(!text.contains("remote-workspace"));
+}
+
+#[test]
+fn machine_navigation_does_not_require_a_local_snapshot_or_surface() {
+ for (cols, rows) in [(100, 28), (36, 18)] {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = remote_profile();
+ let remote = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Reconnecting);
+ state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&remote, Box::new(snapshot()));
+ assert!(state.snapshot.is_none());
+ assert!(state.pane_surface.is_none());
+ let frame = state
+ .compose(cols, rows)
+ .expect("connection chrome without Local");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .unwrap()
+ .rect;
+ let buffer = frame.to_ratatui_buffer().unwrap();
+ let local_row = (local.x..local.right())
+ .map(|x| buffer[(x, local.y)].symbol())
+ .collect::<String>();
+ assert!(!local_row.contains("reconnecting"));
+ assert!(
+ !local_row.contains('◐'),
+ "Local never gets a connection badge"
+ );
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == remote)
+ .unwrap()
+ .rect;
+ let mut outcome = ClientShellInput::default();
+ state.handle_mouse(
+ crossterm::event::MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x + 5,
+ row: hit.y,
+ modifiers: KeyModifiers::NONE,
+ },
+ &mut outcome,
+ );
+ assert!(
+ matches!(outcome.actions.as_slice(), [ClientShellAction::ActivateEndpoint { endpoint_id, .. }] if endpoint_id == &remote)
+ );
+ assert!(
+ state.snapshot.is_none(),
+ "selection is committed only by coherent activation"
+ );
+ }
+}
+
+#[test]
+fn sidebar_renders_local_and_saved_ssh_endpoints_with_status() {
+ let (mut state, _) = state_with_remote();
+ let frame = state.compose(100, 28).expect("combined endpoint 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("Local"));
+ assert!(text.contains("Build"));
+ assert!(text.contains("remote-workspace"));
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+ assert_eq!(
+ buffer[(remote.right() - 1, remote.y)].fg,
+ state.config.palette.green
+ );
+
+ state.sidebar_collapsed = true;
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let local = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("collapsed local machine row")
+ .rect;
+ let remote = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| !hit.endpoint_id.is_local())
+ .expect("collapsed remote machine row")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(buffer[(local.right() - 1, local.y)].symbol(), "●");
+ assert_eq!(buffer[(remote.right() - 1, remote.y)].symbol(), "●");
+}
+
+#[test]
+fn active_workspace_is_the_only_highlight_when_machine_is_expanded() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_ne!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+ assert_eq!(
+ buffer[(workspace.x + 2, workspace.y)].bg,
+ state.config.palette.active_row_bg
+ );
+
+ state.collapsed_endpoints.insert(endpoint_id.clone());
+ let frame = state.compose(100, 28).expect("collapsed endpoint frame");
+ let machine = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote machine hit")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ assert_eq!(
+ buffer[(machine.x, machine.y)].bg,
+ state.config.palette.active_row_bg
+ );
+}
+
+#[test]
+fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let mut config = Config::default();
+ config.ui.status_indicators = StatusIndicatorStyle::Symbols;
+ config.ui.sidebar.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let frame = state.compose(100, 28).expect("combined endpoint 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("○ Local · local agent"), "frame: {text}");
+ assert!(text.contains("× Build · remote agent"), "frame: {text}");
+ assert!(text.contains("grouped"), "frame: {text}");
+ let toggle = state.hits.agent_sort_toggle;
+ assert!(!toggle.is_empty());
+ let click = state.handle_raw_events(vec![RawInputEvent::Mouse(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 buffer = frame
+ .to_ratatui_buffer()
+ .expect("aggregate frame should reconstruct");
+ assert!(buffer
+ .content()
+ .iter()
+ .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red));
+}
+
+#[test]
+fn aggregate_priority_uses_client_observed_recency_across_machines() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::AgentSidebarToken;
+
+ let mut config = Config::default();
+ config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
+ config.ui.sidebar.agents.rows =
+ vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
+ let profile = remote_profile();
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
+ state.set_snapshot(Box::new(local));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+
+ let mut local = snapshot();
+ local.agents = vec![agent("local agent", AgentStatus::Idle, 2)];
+ state.set_snapshot(Box::new(local));
+ let frame_text = |state: &mut ClientShellState| {
+ let frame = state.compose(100, 28).expect("combined endpoint frame");
+ frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Local · local agent").expect("local agent")
+ < text.find("Build · remote agent").expect("remote agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+
+ remote.agents = vec![agent("remote agent", AgentStatus::Idle, 3)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ let text = frame_text(&mut state);
+ assert!(
+ text.find("Build · remote agent").expect("remote agent")
+ < text.find("Local · local agent").expect("local agent")
+ );
+ let mut outcome = ClientShellInput::default();
+ assert!(
+ state.handle_endpoint_navigation(crate::input::KeybindAction::FocusAgent(0), &mut outcome,)
+ );
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+}
+
+#[test]
+fn unselected_endpoint_completion_projects_done_client_side() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ remote.agents = vec![agent("background agent", AgentStatus::Working, 2)];
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote.clone()));
+ remote.revision = 2;
+ remote.agents = vec![agent("background agent", AgentStatus::Idle, 3)];
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+
+ let status = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.agents.first())
+ .map(|agent| agent.agent_status);
+ assert_eq!(status, Some(AgentStatus::Done));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn clicking_remote_endpoint_requests_activation_without_mutating_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let hit = state
+ .hits
+ .machines
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote endpoint hit")
+ .rect;
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: hit.x,
+ row: hit.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+ assert_eq!(
+ state
+ .snapshot
+ .as_deref()
+ .map(|snapshot| snapshot.boot_id.as_str()),
+ Some("boot-1")
+ );
+}
+
+#[test]
+fn context_menu_lookup_ignores_inactive_endpoint_workspaces() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("combined endpoint frame");
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+
+ assert_eq!(
+ state.active_endpoint_workspace_at((remote.x, remote.y)),
+ None
+ );
+ assert_eq!(
+ state.active_endpoint_workspace_at((local.x, local.y)),
+ Some("ws_1".into())
+ );
+}
+
+#[test]
+fn future_surface_waits_for_its_exact_snapshot_revision() {
+ let (mut state, _) = state_with_remote();
+ let mut future = surface();
+ future.projection_revision = 2;
+ future.surface_revision = 2;
+ state.set_pane_surface(future);
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(1)
+ );
+ assert_eq!(
+ state
+ .pending_pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+
+ let mut next = snapshot();
+ next.revision = 2;
+ state.set_snapshot(Box::new(next));
+ assert_eq!(
+ state
+ .pane_surface
+ .as_ref()
+ .map(|surface| surface.projection_revision),
+ Some(2)
+ );
+ assert!(state.pending_pane_surface.is_none());
+}
+
+#[test]
+fn inactive_endpoint_snapshot_cache_never_regresses_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut newest = snapshot();
+ newest.boot_id = "remote-boot".into();
+ newest.revision = 3;
+ newest.workspaces[0].label = "newest".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(newest));
+ let mut delayed = snapshot();
+ delayed.boot_id = "remote-boot".into();
+ delayed.revision = 2;
+ delayed.workspaces[0].label = "delayed".into();
+
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(delayed));
+
+ let label = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .and_then(|endpoint| endpoint.snapshot.as_deref())
+ .and_then(|snapshot| snapshot.workspaces.first())
+ .map(|workspace| workspace.label.as_str());
+ assert_eq!(label, Some("newest"));
+}
+
+#[test]
+fn new_connection_generation_accepts_a_lower_same_boot_projection_revision() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let mut previous = snapshot();
+ previous.boot_id = "shared-server-boot".into();
+ previous.revision = 9;
+ previous.workspaces[0].label = "old connection".into();
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 4, Box::new(previous));
+ let mut reconnected = snapshot();
+ reconnected.boot_id = "shared-server-boot".into();
+ reconnected.revision = 1;
+ reconnected.workspaces[0].label = "new connection".into();
+
+ state.cache_endpoint_snapshot_inactive_for_generation(&endpoint_id, 5, Box::new(reconnected));
+
+ let endpoint = state
+ .endpoints
+ .iter()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ assert_eq!(endpoint.snapshot_generation, Some(5));
+ assert_eq!(endpoint.snapshot.as_ref().unwrap().revision, 1);
+ assert_eq!(
+ endpoint.snapshot.as_ref().unwrap().workspaces[0].label,
+ "new connection"
+ );
+}
+
+#[test]
+fn reconnect_snapshot_waits_for_coherent_activation_before_replacing_projection() {
+ let (mut state, endpoint_id) = state_with_remote();
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let mut replacement = snapshot();
+ replacement.boot_id = "replacement-boot".into();
+ state.cache_endpoint_snapshot(&endpoint_id, Box::new(replacement));
+ assert_eq!(state.snapshot.as_deref().unwrap().boot_id, "remote-boot");
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ assert_eq!(
+ state.snapshot.as_deref().unwrap().boot_id,
+ "replacement-boot"
+ );
+}
+
+#[test]
+fn disconnected_active_endpoint_freezes_surface_and_marks_cached_ui_stale() {
+ use crate::api::schema::AgentStatus;
+ use crate::config::{AgentSidebarToken, StatusIndicatorStyle};
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state.config.status_indicators = StatusIndicatorStyle::Symbols;
+ state.config.agents.rows = vec![vec![
+ AgentSidebarToken::StateIcon,
+ AgentSidebarToken::Machine,
+ AgentSidebarToken::Agent,
+ ]];
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint");
+ endpoint.snapshot.as_mut().expect("remote snapshot").agents =
+ vec![agent("remote agent", AgentStatus::Blocked, 1)];
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.pending_integration_installs = 2;
+
+ state.mark_endpoint_disconnected(&endpoint_id);
+ let frame = state.compose(100, 28).expect("frozen endpoint 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_eq!(state.pending_integration_installs, 0);
+ assert_eq!(
+ state.endpoint_status(&endpoint_id),
+ Some(ClientEndpointStatus::Reconnecting)
+ );
+ assert!(text.contains("◐ reconnecting"), "frame: {text}");
+ assert!(text.contains("Build · remote agent"), "frame: {text}");
+ assert!(
+ text.contains("LIVE"),
+ "frozen surface should remain: {text}"
+ );
+ assert!(state.hits.panes.is_empty());
+ assert!(frame.cursor.is_none());
+ let buffer = frame.to_ratatui_buffer().expect("frame should reconstruct");
+ let stale_icon = buffer
+ .content()
+ .iter()
+ .find(|cell| cell.symbol() == "×")
+ .expect("stale blocked icon");
+ assert_eq!(stale_icon.fg, state.config.palette.overlay0);
+}
+
+#[cfg(unix)]
+#[test]
+fn graphics_scope_qualifies_colliding_boot_ids_by_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let local_scope = state.graphics_scope().to_owned();
+ let mut remote = snapshot();
+ remote.boot_id = "boot-1".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let remote_scope = state.graphics_scope();
+ assert_ne!(local_scope, remote_scope);
+ assert!(remote_scope.starts_with("ssh:0123456789abcdef0123456789abcdef:"));
+}
+
+#[test]
+fn navigator_uses_machine_parents_only_for_federated_clients() {
+ let (mut state, _) = state_with_remote();
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machines = rows
+ .iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Machine { .. }))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ machines
+ .iter()
+ .map(|row| row.label.as_str())
+ .collect::<Vec<_>>(),
+ vec!["Local", "Build"]
+ );
+ assert!(rows.iter().all(|row| {
+ matches!(row.target, ClientNavigatorTarget::Machine { .. })
+ || (!row.label.contains("Local ·") && !row.label.contains("Build ·"))
+ }));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
+ ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(),
+ ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(),
+ }));
+ assert_eq!(rows.iter().filter(|row| row.current).count(), 1);
+
+ let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ local.set_snapshot(Box::new(snapshot()));
+ local.set_pane_surface(surface());
+ let frame = local.compose(100, 28).expect("local-only sidebar");
+ assert!(local.hits.machines.is_empty());
+ assert!(!frame
+ .cells
+ .chunks(frame.width as usize)
+ .map(|row| {
+ row.iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>()
+ })
+ .collect::<Vec<_>>()
+ .join("\n")
+ .contains(" machines"));
+ local.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = local.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&local.endpoints, &local.active_endpoint_id, navigator);
+ assert!(rows
+ .iter()
+ .all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
+ assert!(rows.iter().all(|row| match row.target {
+ ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
+ ClientNavigatorTarget::Tab { .. } => row.depth == 1,
+ ClientNavigatorTarget::Pane { .. } => row.depth == 2,
+ ClientNavigatorTarget::Machine { .. } => false,
+ }));
+}
+
+#[test]
+fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
+ let (mut state, endpoint_id) = state_with_remote();
+ let endpoint = state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("saved remote endpoint");
+ endpoint.snapshot = None;
+ endpoint.status = ClientEndpointStatus::Connecting;
+ state.open_navigator_overlay();
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+
+ assert!(rows.iter().any(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ ) && row.label == "Build"
+ && row.stale
+ }));
+ assert!(!rows.iter().any(|row| match &row.target {
+ ClientNavigatorTarget::Machine { .. } => false,
+ ClientNavigatorTarget::Workspace {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Tab {
+ endpoint_id: target,
+ ..
+ }
+ | ClientNavigatorTarget::Pane {
+ endpoint_id: target,
+ ..
+ } => target == &endpoint_id,
+ }));
+}
+
+#[test]
+fn navigator_machine_selection_opens_its_remembered_view() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .into_iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .map(|row| row.target)
+ .expect("remote machine row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn navigator_foreign_pane_selection_activates_its_endpoint() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote pane row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut local = snapshot();
+ let mut inserted = local.workspaces[0].clone();
+ inserted.workspace_id = "ws_2".into();
+ inserted.focused = false;
+ local.workspaces.push(inserted);
+ state.set_snapshot(Box::new(local));
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+ assert!(state.overlay.is_none());
+}
+
+#[test]
+fn mobile_foreign_agent_and_workspace_targets_activate_their_endpoint() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_agent = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Agent {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote agent target");
+ let click = |rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: rect.x,
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+ let outcome = state.handle_raw_events(vec![click(remote_agent)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Pane(pane_id)),
+ }] if activated == &endpoint_id && pane_id == "pane_1"
+ ));
+
+ state.mode = ClientShellMode::Navigate;
+ state.compose(44, 30).expect("mobile switcher");
+ let remote_workspace = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("remote workspace target");
+ let outcome = state.handle_raw_events(vec![click(remote_workspace)]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
+ }] if activated == &endpoint_id && workspace_id == "ws_1"
+ ));
+}
+
+#[test]
+fn cached_offline_navigator_and_mobile_targets_are_dimmed_and_disabled() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Blocked, 2)];
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ let rows =
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
+ let machine = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Machine { endpoint_id: target } if target == &endpoint_id
+ )
+ })
+ .expect("cached remote machine row");
+ assert!(machine.stale);
+ let row = rows
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Pane {
+ endpoint_id: target_endpoint,
+ pane_id,
+ } if target_endpoint == &endpoint_id && pane_id == "pane_1"
+ )
+ })
+ .expect("cached remote pane row");
+ assert!(row.stale);
+ assert!(!row.meta.contains("reconnecting"));
+ row.target.clone()
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+ let mut navigator_outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut navigator_outcome);
+ assert!(navigator_outcome.actions.is_empty());
+ assert!(matches!(
+ state.overlay,
+ Some(ClientShellOverlay::Navigator(_))
+ ));
+
+ state.overlay = None;
+ state.mode = ClientShellMode::Navigate;
+ let frame = state.compose(44, 30).expect("offline mobile switcher");
+ let mobile_target = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(
+ target,
+ ClientMobileTarget::Workspace {
+ endpoint_id: target_endpoint,
+ workspace_id,
+ } if target_endpoint == &endpoint_id && workspace_id == "ws_1"
+ )
+ .then_some(*rect)
+ })
+ .expect("cached remote workspace target");
+ let buffer = frame.to_ratatui_buffer().expect("mobile frame buffer");
+ assert_eq!(
+ buffer[(mobile_target.x, mobile_target.y)].fg,
+ state.config.palette.overlay0
+ );
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: mobile_target.x,
+ row: mobile_target.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(outcome.actions.is_empty());
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ assert_eq!(state.active_endpoint_id, ClientEndpointId::Local);
+}
+
+#[test]
+fn focus_agent_index_uses_online_aggregate_rows() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .agents = vec![agent("remote agent", AgentStatus::Working, 2)];
+ let focus_agent =
+ |index| crate::input::KeybindMatch::Action(crate::input::KeybindAction::FocusAgent(index));
+
+ assert!(state.indexed_navigation_target_exists(&focus_agent(0)));
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(1)));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting);
+ assert!(!state.indexed_navigation_target_exists(&focus_agent(0)));
+}
+
+#[test]
+fn workspace_drag_rejects_foreign_endpoint_slots() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.compose(100, 28).expect("aggregate sidebar");
+ let local = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id.is_local())
+ .expect("local workspace")
+ .rect;
+ let remote = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let mouse = |kind, rect: Rect| {
+ RawInputEvent::Mouse(MouseEvent {
+ kind,
+ column: rect.x.saturating_add(1),
+ row: rect.y,
+ modifiers: KeyModifiers::empty(),
+ })
+ };
+
+ state.handle_raw_events(vec![mouse(MouseEventKind::Down(MouseButton::Left), local)]);
+ state.handle_raw_events(vec![mouse(MouseEventKind::Drag(MouseButton::Left), remote)]);
+
+ assert!(state.chrome_drag.is_none());
+}
+
+#[test]
+fn collapsed_aggregate_workspace_status_uses_its_status_color() {
+ use crate::api::schema::AgentStatus;
+
+ let (mut state, endpoint_id) = state_with_remote();
+ state
+ .endpoints
+ .iter_mut()
+ .find(|endpoint| endpoint.endpoint_id == endpoint_id)
+ .expect("remote endpoint")
+ .snapshot
+ .as_mut()
+ .expect("remote snapshot")
+ .workspaces[0]
+ .agent_status = AgentStatus::Blocked;
+ state.sidebar_collapsed = true;
+
+ let frame = state.compose(100, 28).expect("collapsed aggregate sidebar");
+ let workspace = state
+ .hits
+ .workspaces
+ .iter()
+ .find(|hit| hit.endpoint_id == endpoint_id)
+ .expect("remote workspace")
+ .rect;
+ let buffer = frame.to_ratatui_buffer().expect("frame buffer");
+ assert_eq!(
+ buffer[(workspace.x.saturating_add(2), workspace.y)].fg,
+ state.config.palette.red
+ );
+}
+
+#[test]
+fn navigator_foreign_tab_selection_keeps_the_tab_target() {
+ let (mut state, endpoint_id) = state_with_remote();
+ state.open_navigator_overlay();
+ let selected = {
+ let ClientShellOverlay::Navigator(navigator) = state.overlay.as_ref().expect("navigator")
+ else {
+ panic!("expected navigator");
+ };
+ render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator)
+ .iter()
+ .find(|row| {
+ matches!(
+ &row.target,
+ ClientNavigatorTarget::Tab {
+ endpoint_id: target_endpoint,
+ tab_id,
+ ..
+ } if target_endpoint == &endpoint_id && tab_id == "tab_1"
+ )
+ })
+ .map(|row| row.target.clone())
+ .expect("remote tab row")
+ };
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = Some(selected);
+ }
+
+ let mut outcome = ClientShellInput::default();
+ state.accept_navigator_selection(&mut outcome);
+
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: Some(ClientEndpointFocusTarget::Tab(tab_id)),
+ }] if activated == &endpoint_id && tab_id == "tab_1"
+ ));
+}
diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs
index d20485b3..b8f17e57 100644
--- a/src/client/shell/tests/keybindings_settings.rs
+++ b/src/client/shell/tests/keybindings_settings.rs
@@ -196,7 +196,10 @@ fn prefix_endpoint_action_uses_public_api_with_stable_ids() {
assert!(state.handle_input_bytes(&[0x02]).actions.is_empty());
let create = state.handle_input_bytes(b"c");
- let [ClientShellAction::Endpoint { boot_id, request }] = &create.actions[..] else {
+ let [ClientShellAction::Endpoint {
+ boot_id, request, ..
+ }] = &create.actions[..]
+ else {
panic!("expected one endpoint action: {:?}", create.actions);
};
assert_eq!(boot_id, "boot-1");
diff --git a/src/client/shell/tests/mobile.rs b/src/client/shell/tests/mobile.rs
index 094b141f..b7cc2a47 100644
--- a/src/client/shell/tests/mobile.rs
+++ b/src/client/shell/tests/mobile.rs
@@ -30,6 +30,7 @@ fn navigate_update_status_uses_released_desktop_and_mobile_placement() {
state.config.tab_bar_position = crate::config::TabBarPositionConfig::Top;
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "bottom notification".into(),
@@ -69,6 +70,74 @@ fn mobile_layout_reserves_only_client_header() {
}
#[test]
+fn mobile_switcher_can_activate_an_online_saved_machine() {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ let profile = crate::client::endpoint::SavedSshEndpoint {
+ id: crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(),
+ label: "Build".into(),
+ target: "build".into(),
+ session: "agents".into(),
+ enabled: true,
+ };
+ let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot()));
+ state.set_pane_surface(surface());
+ let mut remote = snapshot();
+ remote.boot_id = "remote-boot".into();
+ state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
+ state.mode = ClientShellMode::Navigate;
+
+ let frame = state.compose(44, 30).expect("mobile machine switcher");
+ let text = frame
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("machines"));
+ assert!(text.contains("Build"));
+ let machine = state
+ .hits
+ .mobile_targets
+ .iter()
+ .find_map(|(rect, target)| {
+ matches!(target, ClientMobileTarget::Machine(id) if id == &endpoint_id).then_some(*rect)
+ })
+ .expect("saved machine target");
+ let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: machine.x,
+ row: machine.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ assert!(matches!(
+ outcome.actions.as_slice(),
+ [ClientShellAction::ActivateEndpoint {
+ endpoint_id: activated,
+ target: None,
+ }] if activated == &endpoint_id
+ ));
+
+ state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
+ assert!(state.activate_endpoint_projection(&endpoint_id));
+ let mut remote_surface = surface();
+ remote_surface.boot_id = "remote-boot".into();
+ state.set_pane_surface(remote_surface);
+ state.mark_endpoint_disconnected(&endpoint_id);
+ state.receive_endpoint_unavailable("network lost".into());
+ state.mode = ClientShellMode::Navigate;
+ let stale = state.compose(44, 30).expect("stale mobile switcher");
+ let text = stale
+ .cells
+ .iter()
+ .map(|cell| cell.symbol.as_str())
+ .collect::<String>();
+ assert!(text.contains("reconnecting"), "frame: {text}");
+ assert!(text.contains("network lost"), "frame: {text}");
+}
+
+#[test]
fn mobile_shell_controls_remain_clickable_when_pane_mouse_capture_is_disabled() {
let mut config = ClientShellConfig::from_config(&Config::default());
config.mouse_capture = false;
@@ -175,7 +244,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_1"
+ )
+ .then_some(*rect)
})
.expect("workspace hit");
let focused = state.handle_raw_events(vec![click(workspace_hit)]);
@@ -199,7 +272,11 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Agent(id) if id == "pane_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Agent { pane_id, .. } if pane_id == "pane_1"
+ )
+ .then_some(*rect)
})
.expect("agent hit");
let focused = state.handle_raw_events(vec![click(agent_hit)]);
@@ -221,7 +298,14 @@ fn mobile_header_and_switcher_render_released_sections_and_stable_targets() {
.mobile_targets
.iter()
.find_map(|(rect, target)| {
- matches!(target, ClientMobileTarget::Tab(id) if id == "tab_1").then_some(*rect)
+ matches!(
+ target,
+ ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ } if endpoint_id.is_local() && tab_id == "tab_1"
+ )
+ .then_some(*rect)
})
.expect("tab hit");
let focused = state.handle_raw_events(vec![click(tab_hit)]);
@@ -534,7 +618,10 @@ fn mobile_switcher_scroll_close_and_width_transition_clear_mobile_hits() {
assert_eq!(state.navigate_workspace_id.as_deref(), Some("ws_8"));
assert!(state.mobile_switcher_scroll > 2);
assert!(state.hits.mobile_targets.iter().any(|(_, target)| {
- matches!(target, ClientMobileTarget::Workspace(id) if id == "ws_8")
+ matches!(
+ target,
+ ClientMobileTarget::Workspace { workspace_id, .. } if workspace_id == "ws_8"
+ )
}));
let close = state.hits.mobile_close;
state.handle_raw_events(vec![RawInputEvent::Mouse(crossterm::event::MouseEvent {
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index 4cf97152..497ce2c6 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -6,7 +6,7 @@ use crate::protocol::{
};
use crossterm::event::MouseEvent;
-fn snapshot() -> ClientShellSnapshot {
+pub(super) fn snapshot() -> ClientShellSnapshot {
ClientShellSnapshot {
boot_id: "boot-1".into(),
revision: 1,
@@ -211,6 +211,8 @@ fn surface_with_popup() -> PaneSurfaceFrame {
mod agents_worktrees_notifications;
mod chrome_context;
mod copy;
+mod endpoint_requests;
+mod endpoints;
#[path = "input.rs"]
mod input_domain;
mod keybindings_settings;
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 6ccdcf6e..044ef145 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -82,7 +82,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
query: String::new(),
search_focused: false,
- selected: 0,
+ selected: None,
scroll: 0,
filter: None,
expanded_workspaces: HashSet::new(),
diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs
index 2e2901e8..7107aa71 100644
--- a/src/client/shell/tests/startup_overlays.rs
+++ b/src/client/shell/tests/startup_overlays.rs
@@ -541,6 +541,7 @@ fn config_diagnostic_offsets_only_the_pane_rows_it_overlaps() {
state.set_snapshot(Box::new(endpoint_snapshot));
state.set_pane_surface(surface());
state.visible_notification = Some(ClientVisibleNotification {
+ endpoint_id: ClientEndpointId::Local,
event: SemanticNotification {
kind: SemanticNotificationKind::Custom,
title: "notification".into(),