aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell')
-rw-r--r--src/client/shell/actions.rs21
-rw-r--r--src/client/shell/composition.rs18
-rw-r--r--src/client/shell/endpoint_navigation.rs2
-rw-r--r--src/client/shell/endpoint_sidebar.rs15
-rw-r--r--src/client/shell/endpoints.rs6
-rw-r--r--src/client/shell/input.rs1
-rw-r--r--src/client/shell/mobile.rs9
-rw-r--r--src/client/shell/sidebar.rs50
-rw-r--r--src/client/shell/state.rs4
-rw-r--r--src/client/shell/tests/workspace_navigation.rs467
-rw-r--r--src/client/shell/workspace_navigation.rs54
11 files changed, 618 insertions, 29 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index e3885626..da4edd49 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -141,6 +141,7 @@ impl ClientShellState {
return;
}
if action == crate::input::KeybindAction::WorkspacePicker {
+ self.pending_workspace_highlight = None;
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
self.mode = ClientShellMode::Navigate;
@@ -349,6 +350,19 @@ impl ClientShellState {
kind: PendingEndpointKind,
outcome: &mut ClientShellInput,
) -> bool {
+ let changes_focus = match &method {
+ crate::api::schema::Method::WorkspaceFocus(_)
+ | crate::api::schema::Method::TabFocus(_)
+ | crate::api::schema::Method::PaneFocus(_)
+ | crate::api::schema::Method::PaneFocusDirection(_) => true,
+ crate::api::schema::Method::WorkspaceCreate(params) => params.focus,
+ crate::api::schema::Method::TabCreate(params) => params.focus,
+ crate::api::schema::Method::PaneSplit(params) => params.focus,
+ _ => false,
+ };
+ if changes_focus {
+ outcome.repaint |= self.pending_workspace_highlight.take().is_some();
+ }
if !self.endpoint_is_online(&self.active_endpoint_id) {
let label = self.active_endpoint_label().to_owned();
outcome.repaint |= self.receive_endpoint_unavailable(format!("{label} is not ready"));
@@ -495,6 +509,13 @@ impl ClientShellState {
self.endpoint_notice_seen.remove(&timeout_key);
}
if let Err(error) = &result {
+ if self
+ .pending_workspace_highlight
+ .as_ref()
+ .is_some_and(|pending| pending.request_id == request_id)
+ {
+ self.pending_workspace_highlight = None;
+ }
let code = error.code.as_deref().unwrap_or("invalid_response");
if !matches!(
code,
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index 10dc6633..4da21e77 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -40,6 +40,12 @@ impl ClientShellState {
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
+ let pending_workspace_highlight =
+ self.pending_workspace_highlight.as_ref().filter(|pending| {
+ self.mode != ClientShellMode::Navigate
+ && pending.target.endpoint_id == self.active_endpoint_id
+ && self.navigation_target_valid(&pending.target)
+ });
// A resize invalidates pane geometry, not the healthy Local workspace chrome.
let local_snapshot = self.snapshot.as_deref().filter(|_| {
self.endpoints.len() == 1
@@ -65,7 +71,8 @@ impl ClientShellState {
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
- .filter(|_| valid_navigation_target),
+ .filter(|_| valid_navigation_target)
+ .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id: None,
workspace_drop_indicator_row: None,
@@ -143,6 +150,12 @@ impl ClientShellState {
.navigate_workspace_id
.as_ref()
.is_some_and(|target| self.navigation_target_valid(target));
+ let pending_workspace_highlight =
+ self.pending_workspace_highlight.as_ref().filter(|pending| {
+ self.mode != ClientShellMode::Navigate
+ && pending.target.endpoint_id == self.active_endpoint_id
+ && self.navigation_target_valid(&pending.target)
+ });
if self.snapshot.is_none() || self.pane_surface.is_none() {
return Some(self.compose_unavailable(cols, rows));
}
@@ -200,7 +213,8 @@ impl ClientShellState {
selected_workspace_id: self
.navigate_workspace_id
.as_ref()
- .filter(|_| valid_navigation_target),
+ .filter(|_| valid_navigation_target)
+ .or_else(|| pending_workspace_highlight.map(|pending| &pending.target)),
reveal_navigation_workspace: &mut self.reveal_navigation_workspace,
dragged_workspace_id,
workspace_drop_indicator_row,
diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs
index c15fd434..a1f7ac2c 100644
--- a/src/client/shell/endpoint_navigation.rs
+++ b/src/client/shell/endpoint_navigation.rs
@@ -230,6 +230,7 @@ impl ClientShellState {
endpoint_id: ClientEndpointId,
outcome: &mut ClientShellInput,
) -> bool {
+ self.pending_workspace_highlight = None;
self.pending_agent_reveal = None;
let online = self.endpoint_is_online(&endpoint_id);
if !online && !endpoint_id.is_local() {
@@ -255,6 +256,7 @@ impl ClientShellState {
target: ClientEndpointFocusTarget,
outcome: &mut ClientShellInput,
) -> bool {
+ self.pending_workspace_highlight = None;
self.pending_agent_reveal = None;
let online = self.endpoint_is_online(&endpoint_id);
if !online && !endpoint_id.is_local() {
diff --git a/src/client/shell/endpoint_sidebar.rs b/src/client/shell/endpoint_sidebar.rs
index 34d2ccdc..c4f701c3 100644
--- a/src/client/shell/endpoint_sidebar.rs
+++ b/src/client/shell/endpoint_sidebar.rs
@@ -128,7 +128,13 @@ pub(super) fn render_collapsed(
if selected {
buffer.set_style(rect, Style::default().bg(selection_background));
} else if focused {
- buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ buffer.set_style(
+ rect,
+ Style::default().bg(super::sidebar::workspace_active_background(
+ palette,
+ state.selected_workspace_id.is_some(),
+ )),
+ );
}
let stale = endpoint.status != ClientEndpointStatus::Online;
let number = format!(" {}", workspace.number);
@@ -449,19 +455,16 @@ pub(super) fn render_expanded(
super::sidebar::render_workspace_rows(
buffer,
nested,
- workspace,
status,
config.status_indicators,
entry,
tokens,
- endpoint_active,
+ endpoint_active && workspace.focused,
selected,
+ state.selected_workspace_id.is_some(),
false,
palette,
);
- if selected && palette.selection_bg == ratatui::style::Color::Reset {
- buffer.set_style(nested, Style::default().bg(palette.active_row_bg));
- }
if endpoint.status != ClientEndpointStatus::Online {
buffer.set_style(
rect,
diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs
index 3d1d419b..513842ba 100644
--- a/src/client/shell/endpoints.rs
+++ b/src/client/shell/endpoints.rs
@@ -108,6 +108,9 @@ impl ClientShellState {
}
pub(crate) fn retire_endpoint(&mut self, endpoint_id: &ClientEndpointId) {
+ if endpoint_id == &self.active_endpoint_id {
+ self.pending_workspace_highlight = None;
+ }
self.retire_endpoint_notifications(endpoint_id);
if let Some(endpoint) = self
.endpoints
@@ -131,6 +134,9 @@ impl ClientShellState {
endpoint_id: &ClientEndpointId,
status: ClientEndpointStatus,
) {
+ if endpoint_id == &self.active_endpoint_id && status != ClientEndpointStatus::Online {
+ self.pending_workspace_highlight = None;
+ }
if let Some(endpoint) = self
.endpoints
.iter_mut()
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index c6d458c8..f1e7ea25 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -645,6 +645,7 @@ impl ClientShellState {
) {
use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch};
+ self.pending_workspace_highlight = None;
if key.code == KeyCode::Esc
|| crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix)
{
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs
index 8f06f54b..f4ae0af0 100644
--- a/src/client/shell/mobile.rs
+++ b/src/client/shell/mobile.rs
@@ -768,7 +768,13 @@ fn mobile_items(
palette.surface0
}
} else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
- palette.surface_dim
+ if selected_workspace_id.is_some()
+ && palette.surface0 == ratatui::style::Color::Reset
+ {
+ palette.panel_bg
+ } else {
+ palette.surface_dim
+ }
} else {
palette.panel_bg
};
@@ -957,6 +963,7 @@ impl ClientShellState {
{
self.mobile_switcher_scroll = 0;
self.reveal_mobile_workspace = false;
+ self.pending_workspace_highlight = None;
self.mode = ClientShellMode::Navigate;
self.navigate_workspace_id = self.focused_navigation_target();
outcome.repaint = true;
diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs
index 161aecd5..f94f6bdc 100644
--- a/src/client/shell/sidebar.rs
+++ b/src/client/shell/sidebar.rs
@@ -4,6 +4,26 @@ use ratatui::{
widgets::{Paragraph, Widget},
};
+fn workspace_selection_background(palette: &Palette) -> ratatui::style::Color {
+ if palette.selection_bg == ratatui::style::Color::Reset {
+ palette.active_row_bg
+ } else {
+ palette.selection_bg
+ }
+}
+
+pub(in crate::client::shell) fn workspace_active_background(
+ palette: &Palette,
+ navigating: bool,
+) -> ratatui::style::Color {
+ // The fallback cursor shares the active-row color; only fill the cursor while navigating.
+ if navigating && palette.selection_bg == ratatui::style::Color::Reset {
+ palette.sidebar_bg
+ } else {
+ palette.active_row_bg
+ }
+}
+
pub(in crate::client::shell) fn collapsed_sidebar_sections(
area: Rect,
) -> (Rect, Option<u16>, Rect) {
@@ -33,6 +53,8 @@ pub(crate) fn render_collapsed_sidebar(
hits: &mut ShellHitMap,
) {
let palette = &config.palette;
+ let selection_background = workspace_selection_background(palette);
+ let active_background = workspace_active_background(palette, selected_workspace_id.is_some());
render_sidebar_background(buffer, area, palette);
let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
for (index, workspace) in snapshot
@@ -48,23 +70,17 @@ pub(crate) fn render_collapsed_sidebar(
1,
);
let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
- let selection_background =
- if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset {
- palette.active_row_bg
- } else {
- palette.selection_bg
- };
if selected {
buffer.set_style(rect, Style::default().bg(selection_background));
} else if workspace.focused {
- buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ buffer.set_style(rect, Style::default().bg(active_background));
}
let number_style = if selected {
Style::default()
.fg(palette.overlay1)
.bg(selection_background)
} else if workspace.focused {
- Style::default().fg(palette.text).bg(palette.active_row_bg)
+ Style::default().fg(palette.text).bg(active_background)
} else {
Style::default().fg(palette.overlay0)
};
@@ -308,13 +324,13 @@ pub(crate) fn render_sidebar(
render_workspace_rows(
buffer,
rect,
- workspace,
status,
config.status_indicators,
entry,
rows,
- true,
+ workspace.focused,
selected,
+ state.selected_workspace_id.is_some(),
dragged,
palette,
);
@@ -639,13 +655,13 @@ pub(in crate::client::shell) fn workspace_rows(
pub(in crate::client::shell) fn render_workspace_rows(
buffer: &mut Buffer,
area: Rect,
- workspace: &ClientShellWorkspace,
status: crate::api::schema::AgentStatus,
indicators: crate::config::StatusIndicatorStyle,
entry: &WorkspaceEntry,
rows: Vec<Vec<crate::ui::ResolvedToken>>,
- endpoint_active: bool,
+ focused: bool,
selected: bool,
+ navigating: bool,
dragged: bool,
palette: &Palette,
) {
@@ -680,7 +696,7 @@ pub(in crate::client::shell) fn render_workspace_rows(
} else {
x = x.saturating_add(3);
}
- let highlighted = endpoint_active && workspace.focused || dragged;
+ let highlighted = focused || dragged;
let workspace_style = Style::default()
.fg(if highlighted {
palette.text
@@ -692,7 +708,7 @@ pub(in crate::client::shell) fn render_workspace_rows(
} else {
Modifier::empty()
});
- let secondary_style = Style::default().fg(if endpoint_active && workspace.focused {
+ let secondary_style = Style::default().fg(if focused {
palette.mauve
} else {
palette.overlay0
@@ -717,11 +733,11 @@ pub(in crate::client::shell) fn render_workspace_rows(
}
let background = if selected {
- Some(palette.selection_bg)
+ Some(workspace_selection_background(palette))
} else if dragged {
Some(palette.surface1)
- } else if endpoint_active && workspace.focused {
- Some(palette.active_row_bg)
+ } else if focused {
+ Some(workspace_active_background(palette, navigating))
} else {
None
};
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 7128db57..55a065d5 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -880,6 +880,7 @@ pub(crate) struct ClientShellState {
pub(super) collapsed_endpoints: HashSet<ClientEndpointId>,
pub(super) mode: ClientShellMode,
pub(super) navigate_workspace_id: Option<WorkspaceNavigationTarget>,
+ pub(super) pending_workspace_highlight: Option<PendingWorkspaceHighlight>,
pub(super) reveal_navigation_workspace: bool,
pub(super) overlay: Option<ClientShellOverlay>,
pub(super) previous_pane_id: Option<String>,
@@ -1043,6 +1044,7 @@ impl ClientShellState {
collapsed_endpoints: HashSet::new(),
mode: ClientShellMode::Terminal,
navigate_workspace_id: None,
+ pending_workspace_highlight: None,
reveal_navigation_workspace: false,
overlay,
previous_pane_id: None,
@@ -1233,6 +1235,7 @@ impl ClientShellState {
self.endpoint_error = None;
self.endpoint_error_deadline = None;
self.navigate_workspace_id = None;
+ self.pending_workspace_highlight = None;
self.overlay = self
.config
.startup_onboarding
@@ -1549,6 +1552,7 @@ impl ClientShellState {
}
}
self.snapshot = Some(snapshot);
+ self.reconcile_pending_workspace_highlight();
let pending_surface = self.pending_pane_surface.take();
if let Some(surface) = pending_surface {
let matching = self.snapshot.as_ref().is_some_and(|snapshot| {
diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs
index 7ea02066..c117a319 100644
--- a/src/client/shell/tests/workspace_navigation.rs
+++ b/src/client/shell/tests/workspace_navigation.rs
@@ -72,6 +72,81 @@ fn workspace_rect(state: &ClientShellState, endpoint: &ClientEndpointId, workspa
}
#[test]
+fn local_navigation_highlight_stays_visible_with_terminal_theme() {
+ use ratatui::style::Color;
+
+ for compact in [false, true] {
+ for selection_bg in [Color::Reset, Color::Rgb(70, 63, 93)] {
+ let mut config = ClientShellConfig::from_config(&Config::default());
+ config.palette = Palette::terminal();
+ config.palette.selection_bg = selection_bg;
+ let expected_bg = if selection_bg == Color::Reset {
+ config.palette.active_row_bg
+ } else {
+ selection_bg
+ };
+ let mut state = ClientShellState::new(config);
+ state.set_snapshot(Box::new(workspaces(3)));
+ state.set_pane_surface(surface());
+ state.sidebar_collapsed = compact;
+ state.compose(100, 28).unwrap();
+ enter_navigation(&mut state);
+
+ for workspace_id in ["ws_1", "ws_2"] {
+ assert_selected(&state, &ClientEndpointId::Local, workspace_id);
+ let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
+ let selected = workspace_rect(&state, &ClientEndpointId::Local, workspace_id);
+ for y in selected.y..selected.bottom() {
+ for x in selected.x..selected.right() {
+ assert_eq!(
+ buffer[(x, y)].bg,
+ expected_bg,
+ "compact={compact}, {workspace_id}, ({x}, {y})"
+ );
+ }
+ }
+ let untouched = workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
+ assert_ne!(buffer[(untouched.x, untouched.y)].bg, expected_bg);
+ if workspace_id != "ws_1" {
+ let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
+ assert_eq!(
+ buffer[(focused.x, focused.y)].bg,
+ if selection_bg == Color::Reset {
+ state.config.palette.sidebar_bg
+ } else {
+ state.config.palette.active_row_bg
+ }
+ );
+ assert_ne!(buffer[(focused.x, focused.y)].bg, expected_bg);
+ }
+ preview_key(&mut state, b"\x1b[B");
+ }
+ assert_eq!(
+ state
+ .snapshot
+ .as_ref()
+ .unwrap()
+ .focused_workspace_id
+ .as_deref(),
+ Some("ws_1")
+ );
+ preview_key(&mut state, b"\x1b");
+ let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
+ let focused = workspace_rect(&state, &ClientEndpointId::Local, "ws_1");
+ assert_eq!(
+ buffer[(focused.x, focused.y)].bg,
+ state.config.palette.active_row_bg
+ );
+ let cancelled = workspace_rect(&state, &ClientEndpointId::Local, "ws_3");
+ assert_eq!(
+ buffer[(cancelled.x, cancelled.y)].bg,
+ state.config.palette.sidebar_bg
+ );
+ }
+ }
+}
+
+#[test]
fn navigation_highlights_only_the_preview_and_activates_on_enter() {
for (compact, cols) in [(true, 100), (false, 100), (false, 44)] {
for terminal_theme in [false, true] {
@@ -113,7 +188,13 @@ fn navigation_highlights_only_the_preview_and_activates_on_enter() {
assert_ne!(buffer[(other.x + 2, other.y)].bg, color);
assert_eq!(
buffer[(focused.x + 2, focused.y)].bg,
- if cols == 44 {
+ if terminal_theme {
+ if cols == 44 {
+ palette.panel_bg
+ } else {
+ palette.sidebar_bg
+ }
+ } else if cols == 44 {
palette.surface_dim
} else {
palette.active_row_bg
@@ -503,3 +584,387 @@ fn aggregate_navigation_reveals_overflow_and_preserves_order() {
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
}
}
+
+fn local_navigation_state(compact: bool) -> ClientShellState {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.config.palette = Palette::terminal();
+ state.sidebar_collapsed = compact;
+ state.set_snapshot(Box::new(workspaces(3)));
+ state.set_pane_surface(surface());
+ state.compose(100, 28).unwrap();
+ state
+}
+
+fn request_local_navigation(state: &mut ClientShellState, down: usize) -> String {
+ enter_navigation(state);
+ for _ in 0..down {
+ preview_key(state, b"\x1b[B");
+ }
+ let outcome = state.handle_input_bytes(b"\r");
+ let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else {
+ panic!("expected a local workspace focus request");
+ };
+ assert!(matches!(
+ request.method,
+ crate::api::schema::Method::WorkspaceFocus(_)
+ ));
+ request.id.clone()
+}
+
+fn assert_local_highlight(state: &mut ClientShellState, selected_id: &str) {
+ let buffer = state.compose(100, 28).unwrap().to_ratatui_buffer().unwrap();
+ for workspace_id in ["ws_1", "ws_2", "ws_3"] {
+ let rect = workspace_rect(state, &ClientEndpointId::Local, workspace_id);
+ assert_eq!(
+ (rect.x..rect.right())
+ .any(|x| buffer[(x, rect.y)].bg == state.config.palette.active_row_bg),
+ workspace_id == selected_id,
+ "expected only {selected_id} highlighted, checking {workspace_id}"
+ );
+ }
+}
+
+fn set_local_focus(state: &mut ClientShellState, workspace_id: &str, revision: u64) {
+ let mut snapshot = workspaces(3);
+ snapshot.revision = revision;
+ snapshot.focused_workspace_id = Some(workspace_id.into());
+ for workspace in &mut snapshot.workspaces {
+ workspace.focused = workspace.workspace_id == workspace_id;
+ }
+ state.set_snapshot(Box::new(snapshot));
+ let mut frame = surface();
+ frame.projection_revision = revision;
+ state.set_pane_surface(frame);
+}
+
+#[test]
+fn accepted_local_navigation_keeps_highlight_until_authoritative_focus() {
+ for compact in [false, true] {
+ for response_first in [false, true] {
+ let mut state = local_navigation_state(compact);
+ let request_id = request_local_navigation(&mut state, 2);
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.navigate_workspace_id.is_none());
+ assert_eq!(
+ state
+ .snapshot
+ .as_ref()
+ .unwrap()
+ .focused_workspace_id
+ .as_deref(),
+ Some("ws_1")
+ );
+ assert_eq!(state.focused_pane_id().as_deref(), Some("pane_1"));
+ assert_local_highlight(&mut state, "ws_3");
+ state.invalidate_pane_surface();
+ assert_local_highlight(&mut state, "ws_3");
+ state.set_pane_surface(surface());
+ if response_first {
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Ok(crate::api::schema::ResponseResult::Ok {}),
+ );
+ assert_local_highlight(&mut state, "ws_3");
+ }
+ set_local_focus(&mut state, "ws_1", 2);
+ assert_local_highlight(&mut state, "ws_3");
+ set_local_focus(&mut state, "ws_3", 3);
+ assert_local_highlight(&mut state, "ws_3");
+ if !response_first {
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Ok(crate::api::schema::ResponseResult::Ok {}),
+ );
+ }
+ set_local_focus(&mut state, "ws_2", 4);
+ assert_local_highlight(&mut state, "ws_2");
+ }
+ }
+}
+
+#[test]
+fn failed_local_navigation_releases_only_its_own_highlight() {
+ for failure in ["rejected", "endpoint_timeout", "cancelled"] {
+ let mut state = local_navigation_state(false);
+ let request_id = request_local_navigation(&mut state, 2);
+ assert_local_highlight(&mut state, "ws_3");
+ if failure == "cancelled" {
+ assert!(state.cancel_endpoint_request(&request_id));
+ } else {
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Err(ClientShellEndpointError {
+ code: Some(failure.into()),
+ message: "focus failed".into(),
+ }),
+ );
+ }
+ assert_local_highlight(&mut state, "ws_1");
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Ok(crate::api::schema::ResponseResult::Ok {}),
+ );
+ assert_local_highlight(&mut state, "ws_1");
+ }
+ for old_down in [1, 2] {
+ let mut state = local_navigation_state(false);
+ let old_request = request_local_navigation(&mut state, old_down);
+ let latest_request = request_local_navigation(&mut state, 2);
+ state.cancel_endpoint_request(&old_request);
+ assert_local_highlight(&mut state, "ws_3");
+ state.cancel_endpoint_request(&latest_request);
+ assert_local_highlight(&mut state, "ws_1");
+ }
+}
+
+#[test]
+fn pending_navigation_highlight_does_not_survive_identity_changes() {
+ for change in [
+ "disconnect",
+ "retire",
+ "boot",
+ "generation",
+ "deleted",
+ "endpoint",
+ ] {
+ let mut state = local_navigation_state(false);
+ let request_id = request_local_navigation(&mut state, 2);
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Ok(crate::api::schema::ResponseResult::Ok {}),
+ );
+ assert_local_highlight(&mut state, "ws_3");
+ let mut snapshot = workspaces(3);
+ match change {
+ "disconnect" => {
+ state.mark_endpoint_disconnected(&ClientEndpointId::Local);
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot));
+ }
+ "retire" => {
+ state.retire_endpoint(&ClientEndpointId::Local);
+ state.set_endpoint_status(&ClientEndpointId::Local, ClientEndpointStatus::Online);
+ state.set_snapshot(Box::new(snapshot));
+ }
+ "boot" => {
+ snapshot.boot_id = "replacement-boot".into();
+ state.set_snapshot(Box::new(snapshot));
+ }
+ "generation" => state.set_endpoint_snapshot_for_generation(
+ &ClientEndpointId::Local,
+ 1,
+ Box::new(snapshot),
+ ),
+ "deleted" => {
+ snapshot.workspaces.pop();
+ state.set_snapshot(Box::new(snapshot));
+ state.set_snapshot(Box::new(workspaces(3)));
+ }
+ "endpoint" => {
+ let profile = remote_profile();
+ let remote = ClientEndpointId::Ssh(profile.id.clone());
+ state.set_endpoint_catalog(&[profile]);
+ state.set_endpoint_status(&remote, ClientEndpointStatus::Online);
+ state.set_endpoint_snapshot(&remote, Box::new(snapshot));
+ assert!(state.activate_endpoint_projection(&remote));
+ assert!(state.pending_workspace_highlight.is_none());
+ assert!(state.activate_endpoint_projection(&ClientEndpointId::Local));
+ state.set_endpoint_catalog(&[]);
+ }
+ _ => unreachable!(),
+ }
+ assert!(state.pending_workspace_highlight.is_none(), "{change}");
+ let mut frame = surface();
+ frame.boot_id = state.snapshot.as_ref().unwrap().boot_id.clone();
+ state.set_pane_surface(frame);
+ assert_local_highlight(&mut state, "ws_1");
+ }
+}
+
+#[test]
+fn navigation_highlight_requires_enqueued_focus_and_yields_to_new_intent() {
+ let mut state = local_navigation_state(false);
+ state.set_endpoint_methods(Some(Vec::new()));
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b[B");
+ let refused = state.handle_input_bytes(b"\r");
+ assert!(refused.actions.is_empty());
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert!(state.pending_workspace_highlight.is_none());
+ // The unsupported-action notice spans the sidebar on this narrow frame.
+ assert!(state.visible_endpoint_notice.take().is_some());
+ assert_local_highlight(&mut state, "ws_1");
+
+ state.set_endpoint_methods(None);
+ request_local_navigation(&mut state, 2);
+ enter_navigation(&mut state);
+ preview_key(&mut state, b"\x1b");
+ assert_local_highlight(&mut state, "ws_1");
+
+ request_local_navigation(&mut state, 2);
+ let mut unrelated = ClientShellInput::default();
+ state.push_endpoint_method(
+ crate::api::schema::Method::ServerReloadConfig(crate::api::schema::EmptyParams::default()),
+ &mut unrelated,
+ );
+ let [ClientShellAction::Endpoint { request, .. }] = unrelated.actions.as_slice() else {
+ panic!("expected unrelated request");
+ };
+ state.cancel_endpoint_request(&request.id);
+ assert_local_highlight(&mut state, "ws_3");
+ let mut focus = ClientShellInput::default();
+ state.focus_or_activate(
+ ClientEndpointId::Local,
+ ClientEndpointFocusTarget::Workspace("ws_2".into()),
+ &mut focus,
+ );
+ assert!(state.pending_workspace_highlight.is_none());
+}
+
+#[test]
+fn directional_pane_focus_releases_an_accepted_workspace_highlight() {
+ use crate::api::schema::{Method, PaneDirection, ResponseResult};
+
+ for (key, direction) in [
+ (b'h', PaneDirection::Left),
+ (b'j', PaneDirection::Down),
+ (b'k', PaneDirection::Up),
+ (b'l', PaneDirection::Right),
+ ] {
+ for rejected in [false, true] {
+ let mut state = local_navigation_state(false);
+ let pending_request = request_local_navigation(&mut state, 2);
+ assert_local_highlight(&mut state, "ws_3");
+ preview_key(&mut state, &[0x02]);
+ let outcome = state.handle_input_bytes(&[key]);
+ let [ClientShellAction::Endpoint { request, .. }] = outcome.actions.as_slice() else {
+ panic!("expected a directional pane focus request");
+ };
+ let Method::PaneFocusDirection(params) = &request.method else {
+ panic!("expected PaneFocusDirection");
+ };
+ assert_eq!(params.direction, direction);
+ assert_eq!(params.pane_id.as_deref(), Some("pane_1"));
+ assert!(state.pending_workspace_highlight.is_none());
+ assert_local_highlight(&mut state, "ws_1");
+ let result = if rejected {
+ Err(ClientShellEndpointError {
+ code: Some("rejected".into()),
+ message: "focus rejected".into(),
+ })
+ } else {
+ Ok(ResponseResult::Ok {})
+ };
+ state.handle_endpoint_result("boot-1", &pending_request, result);
+ assert_local_highlight(&mut state, "ws_1");
+ }
+ }
+}
+
+#[test]
+fn direct_agent_focus_repaints_when_releasing_a_workspace_highlight() {
+ let mut config = Config::default();
+ config.keys.focus_agent = crate::config::BindingConfig::one("ctrl+alt+1");
+ let mut projected = workspaces(3);
+ projected.agents.push(agent("agent", AgentStatus::Idle, 1));
+
+ for pending in [false, true] {
+ let mut state = local_navigation_state(false);
+ state.config.keybinds = ClientShellConfig::from_config(&config).keybinds;
+ state.set_snapshot(Box::new(projected.clone()));
+ state.compose(100, 28).unwrap();
+ if pending {
+ request_local_navigation(&mut state, 2);
+ assert_local_highlight(&mut state, "ws_3");
+ }
+
+ // Direct bindings do not inherit the repaint from leaving prefix mode.
+ let outcome =
+ state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
+ KeyCode::Char('1'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))]);
+ assert!(
+ matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }]
+ if matches!(&request.method, crate::api::schema::Method::PaneFocus(params)
+ if params.pane_id == "pane_1"))
+ );
+ assert!(state.pending_workspace_highlight.is_none());
+ assert_eq!(outcome.repaint, pending);
+ assert_local_highlight(&mut state, "ws_1");
+ }
+}
+
+#[test]
+fn cancelled_close_does_not_restore_an_older_navigation_highlight() {
+ let mut state = local_navigation_state(false);
+ request_local_navigation(&mut state, 2);
+ state.open_confirm_close_overlay("ws_1".into());
+ preview_key(&mut state, b"\x1b");
+ assert_eq!(state.mode, ClientShellMode::Navigate);
+ preview_key(&mut state, b"\x1b");
+ assert_eq!(state.mode, ClientShellMode::Terminal);
+ assert_local_highlight(&mut state, "ws_1");
+}
+
+#[test]
+fn coalesced_navigation_focus_does_not_leave_a_permanent_highlight() {
+ let mut state = local_navigation_state(false);
+ let before_request = std::time::Instant::now();
+ let request_id = request_local_navigation(&mut state, 2);
+ state.handle_endpoint_result(
+ "boot-1",
+ &request_id,
+ Ok(crate::api::schema::ResponseResult::Ok {}),
+ );
+ // Another client can focus the original workspace before the server projects
+ // either change, so a successful request need not produce a new snapshot.
+ assert!(!state.tick_workspace_highlight(before_request));
+ assert_local_highlight(&mut state, "ws_3");
+ let now = std::time::Instant::now();
+ assert!(state.tick_workspace_highlight(now + std::time::Duration::from_secs(2)));
+ assert_local_highlight(&mut state, "ws_1");
+ assert!(!state.tick_workspace_highlight(now + std::time::Duration::from_secs(3)));
+}
+
+#[test]
+fn navigation_highlight_ends_for_noop_focus_and_focused_creation() {
+ let mut state = local_navigation_state(false);
+ request_local_navigation(&mut state, 0);
+ assert!(state.pending_workspace_highlight.is_none());
+ set_local_focus(&mut state, "ws_2", 2);
+ assert_local_highlight(&mut state, "ws_2");
+
+ for focus in [false, true] {
+ for method in [
+ crate::api::schema::Method::WorkspaceCreate(
+ crate::api::schema::WorkspaceCreateParams {
+ source_workspace_id: None,
+ cwd: None,
+ focus,
+ label: None,
+ env: Default::default(),
+ },
+ ),
+ crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams {
+ workspace_id: Some("ws_1".into()),
+ cwd: None,
+ focus,
+ label: None,
+ env: Default::default(),
+ }),
+ ] {
+ let mut state = local_navigation_state(false);
+ request_local_navigation(&mut state, 2);
+ let mut outcome = ClientShellInput::default();
+ state.push_endpoint_method(method, &mut outcome);
+ assert_eq!(state.pending_workspace_highlight.is_none(), focus);
+ assert_local_highlight(&mut state, if focus { "ws_1" } else { "ws_3" });
+ }
+ }
+}
diff --git a/src/client/shell/workspace_navigation.rs b/src/client/shell/workspace_navigation.rs
index 6139fe0d..19fa1a39 100644
--- a/src/client/shell/workspace_navigation.rs
+++ b/src/client/shell/workspace_navigation.rs
@@ -9,6 +9,13 @@ pub(super) struct WorkspaceNavigationTarget {
generation: Option<u64>,
}
+/// Display-only continuity while a direct focus request awaits its authoritative snapshot.
+pub(super) struct PendingWorkspaceHighlight {
+ pub(super) target: WorkspaceNavigationTarget,
+ pub(super) request_id: String,
+ expires_at: std::time::Instant,
+}
+
impl WorkspaceNavigationTarget {
pub(super) fn matches(&self, endpoint_id: &ClientEndpointId, workspace_id: &str) -> bool {
&self.endpoint_id == endpoint_id && self.workspace_id == workspace_id
@@ -16,6 +23,35 @@ impl WorkspaceNavigationTarget {
}
impl ClientShellState {
+ pub(crate) fn tick_workspace_highlight(&mut self, now: std::time::Instant) -> bool {
+ if self
+ .pending_workspace_highlight
+ .as_ref()
+ .is_some_and(|pending| now >= pending.expires_at)
+ {
+ self.pending_workspace_highlight = None;
+ return true;
+ }
+ false
+ }
+
+ pub(super) fn reconcile_pending_workspace_highlight(&mut self) {
+ if self
+ .pending_workspace_highlight
+ .as_ref()
+ .is_some_and(|pending| {
+ pending.target.endpoint_id != self.active_endpoint_id
+ || !self.navigation_target_valid(&pending.target)
+ || self.snapshot.as_deref().is_some_and(|snapshot| {
+ snapshot.focused_workspace_id.as_deref()
+ == Some(pending.target.workspace_id.as_str())
+ })
+ })
+ {
+ self.pending_workspace_highlight = None;
+ }
+ }
+
pub(super) fn navigation_target(
&self,
endpoint_id: &ClientEndpointId,
@@ -140,11 +176,25 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
+ let action_index = outcome.actions.len();
if self.focus_or_activate(
- target.endpoint_id,
- ClientEndpointFocusTarget::Workspace(target.workspace_id),
+ target.endpoint_id.clone(),
+ ClientEndpointFocusTarget::Workspace(target.workspace_id.clone()),
outcome,
) {
+ // Endpoint handoffs already retain their coherent source frame in the runtime.
+ // Only retain a highlight if a focus request was actually enqueued.
+ if let Some(ClientShellAction::Endpoint { request, .. }) =
+ outcome.actions.get(action_index)
+ {
+ self.pending_workspace_highlight = Some(PendingWorkspaceHighlight {
+ target,
+ request_id: request.id.clone(),
+ // A later focus can be coalesced with this one before a snapshot is sent.
+ expires_at: std::time::Instant::now() + std::time::Duration::from_secs(1),
+ });
+ self.reconcile_pending_workspace_highlight();
+ }
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
}