diff options
Diffstat (limited to 'src/client/shell')
| -rw-r--r-- | src/client/shell/agent_sidebar.rs | 141 | ||||
| -rw-r--r-- | src/client/shell/aggregate_navigation.rs | 211 | ||||
| -rw-r--r-- | src/client/shell/endpoint_agent_state.rs | 12 | ||||
| -rw-r--r-- | src/client/shell/endpoint_agents.rs | 43 | ||||
| -rw-r--r-- | src/client/shell/endpoint_navigation.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/endpoints.rs | 184 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/mobile.rs | 7 | ||||
| -rw-r--r-- | src/client/shell/tests/endpoints.rs | 427 |
9 files changed, 921 insertions, 106 deletions
diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index e7ebabf3..e43ab8cc 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -241,76 +241,83 @@ pub(super) fn agent_rows( ) -> Vec<AgentRow> { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() - .filter_map(|pane_id| { - let agent = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id)?; - let workspace = snapshot - .workspaces - .iter() - .find(|workspace| workspace.workspace_id == agent.workspace_id)?; - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); - let pane = snapshot - .panes - .iter() - .find(|pane| pane.pane_id == agent.pane_id); - let tab_count = snapshot - .tabs - .iter() - .filter(|candidate| candidate.workspace_id == agent.workspace_id) - .count(); - let tab_label = tab - .filter(|tab| tab_count > 1 || tab.custom_label) - .map(|tab| tab.label.as_str()); - let agent_label = agent - .display_agent - .as_deref() - .or(agent.name.as_deref()) - .or(agent.agent.as_deref()) - .or(agent.title.as_deref()); - let labels = agent - .state_labels - .iter() - .cloned() - .collect::<HashMap<_, _>>(); - let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>(); - let state_text = labels - .get(status_text(agent.agent_status)) - .map(String::as_str) - .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); - let canonical_agent = agent - .agent - .as_deref() - .and_then(crate::detect::parse_agent_label); - let rows = crate::ui::sidebar_agent_rows( - &config.agents, - crate::ui::AgentTokenContext { - machine, - workspace: &workspace.label, - tab: tab_label, - pane: agent - .title - .as_deref() - .or_else(|| pane.and_then(|pane| pane.label.as_deref())), - agent_label, - terminal_title: agent.terminal_title.as_deref(), - terminal_title_stripped: agent.terminal_title_stripped.as_deref(), - canonical_agent, - tokens: &tokens, - }, - state_text, - ); - Some(AgentRow { - pane_id: agent.pane_id.clone(), - status: agent.agent_status, - focused: agent.focused, - rows, - }) - }) + .filter_map(|pane_id| agent_row(snapshot, &pane_id, config, machine)) .collect() } +pub(super) fn agent_row( + snapshot: &ClientShellSnapshot, + pane_id: &str, + config: &ClientShellConfig, + machine: Option<&str>, +) -> Option<AgentRow> { + let agent = snapshot + .agents + .iter() + .find(|agent| agent.pane_id == pane_id)?; + let workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == agent.workspace_id)?; + let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let pane = snapshot + .panes + .iter() + .find(|pane| pane.pane_id == agent.pane_id); + let tab_count = snapshot + .tabs + .iter() + .filter(|candidate| candidate.workspace_id == agent.workspace_id) + .count(); + let tab_label = tab + .filter(|tab| tab_count > 1 || tab.custom_label) + .map(|tab| tab.label.as_str()); + let agent_label = agent + .display_agent + .as_deref() + .or(agent.name.as_deref()) + .or(agent.agent.as_deref()) + .or(agent.title.as_deref()); + let labels = agent + .state_labels + .iter() + .cloned() + .collect::<HashMap<_, _>>(); + let tokens = agent.tokens.iter().cloned().collect::<HashMap<_, _>>(); + let state_text = labels + .get(status_text(agent.agent_status)) + .map(String::as_str) + .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); + let canonical_agent = agent + .agent + .as_deref() + .and_then(crate::detect::parse_agent_label); + let rows = crate::ui::sidebar_agent_rows( + &config.agents, + crate::ui::AgentTokenContext { + machine, + workspace: &workspace.label, + tab: tab_label, + pane: agent + .title + .as_deref() + .or_else(|| pane.and_then(|pane| pane.label.as_deref())), + agent_label, + terminal_title: agent.terminal_title.as_deref(), + terminal_title_stripped: agent.terminal_title_stripped.as_deref(), + canonical_agent, + tokens: &tokens, + }, + state_text, + ); + Some(AgentRow { + pane_id: agent.pane_id.clone(), + status: agent.agent_status, + focused: agent.focused, + rows, + }) +} + pub(super) fn render_agent_row( buffer: &mut Buffer, rect: Rect, diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index 5c826bf3..c0195f93 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -5,11 +5,13 @@ use crate::protocol::ClientShellAgent; #[derive(Clone, Copy)] pub(super) struct CachedEndpointSnapshot<'a> { + pub(super) endpoint_index: usize, pub(super) endpoint_id: &'a ClientEndpointId, pub(super) label: &'a str, pub(super) status: ClientEndpointStatus, pub(super) snapshot: &'a ClientShellSnapshot, pub(super) agent_recency: &'a HashMap<String, u64>, + pub(super) agent_presentation: &'a super::endpoint_agent_state::EndpointAgentPresentation, } impl CachedEndpointSnapshot<'_> { @@ -21,18 +23,23 @@ impl CachedEndpointSnapshot<'_> { pub(super) fn cached_endpoint_snapshots( endpoints: &[ClientShellEndpoint], ) -> impl Iterator<Item = CachedEndpointSnapshot<'_>> { - endpoints.iter().filter_map(|endpoint| { - endpoint - .snapshot - .as_deref() - .map(|snapshot| CachedEndpointSnapshot { - endpoint_id: &endpoint.endpoint_id, - label: &endpoint.label, - status: endpoint.status, - snapshot, - agent_recency: &endpoint.agent_recency, - }) - }) + endpoints + .iter() + .enumerate() + .filter_map(|(endpoint_index, endpoint)| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| CachedEndpointSnapshot { + endpoint_index, + endpoint_id: &endpoint.endpoint_id, + label: &endpoint.label, + status: endpoint.status, + snapshot, + agent_recency: &endpoint.agent_recency, + agent_presentation: &endpoint.agent_presentation, + }) + }) } pub(super) struct AggregateAgentRow<'a> { @@ -46,10 +53,83 @@ pub(super) struct AggregateAgentTarget { pub(super) pane_id: String, } -pub(super) fn aggregate_agent_rows( - endpoints: &[ClientShellEndpoint], +pub(super) fn aggregate_agent_rows<'a>( + endpoints: &'a [ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, sort: crate::config::AgentPanelSortConfig, -) -> Vec<AggregateAgentRow<'_>> { +) -> Vec<AggregateAgentRow<'a>> { + let active_index = endpoints + .iter() + .position(|endpoint| &endpoint.endpoint_id == active_endpoint_id); + let active_view = active_index.and_then(|index| { + let endpoint = &endpoints[index]; + if !endpoint.agent_view_projection_supported { + return None; + } + match ClientShellState::endpoint_agent_view(endpoint) { + Some(Ok(view)) => Some(Ok(view.as_ref())), + Some(Err(())) => Some(Err(())), + None if endpoint + .snapshot + .as_deref() + .is_some_and(|snapshot| snapshot.agent_view_label.is_none()) => + { + Some(Ok(None)) + } + None => Some(Err(())), + } + }); + + if let Some(Ok(view)) = active_view { + let mut rows = cached_endpoint_snapshots(endpoints) + .flat_map(|endpoint| { + endpoint + .snapshot + .agents + .iter() + .map(move |agent| AggregateAgentRow { + recency: endpoint + .agent_recency + .get(&agent.pane_id) + .copied() + .unwrap_or_default(), + endpoint, + agent, + }) + }) + .collect::<Vec<_>>(); + if let Some(view) = view { + let context = active_index + .and_then(|index| endpoints[index].snapshot.as_deref()) + .map(|snapshot| crate::agent_view_eval::AgentViewContext { + scope: active_index.unwrap_or_default(), + workspace_id: snapshot.focused_workspace_id.clone(), + tab_id: snapshot.focused_tab_id.clone(), + }); + if let (Some(context), Some(filter)) = (context.as_ref(), view.filter.as_ref()) { + rows.retain(|row| { + crate::agent_view_eval::matches_filter( + context, + &ClientAgentViewEntry::new(row), + filter, + ) + }); + } + if !view.sort.is_empty() { + rows.sort_by(|left, right| { + crate::agent_view_eval::compare_entries( + &ClientAgentViewEntry::new(left), + &ClientAgentViewEntry::new(right), + &view.sort, + ) + }); + return rows; + } + } + sort_aggregate_rows(&mut rows, sort); + return rows; + } + let mut rows = cached_endpoint_snapshots(endpoints) .flat_map(|endpoint| { super::agent_sidebar::ordered_agent_pane_ids(endpoint.snapshot, sort) @@ -72,6 +152,14 @@ pub(super) fn aggregate_agent_rows( }) }) .collect::<Vec<_>>(); + sort_aggregate_rows(&mut rows, sort); + rows +} + +fn sort_aggregate_rows( + rows: &mut [AggregateAgentRow<'_>], + sort: crate::config::AgentPanelSortConfig, +) { if sort == crate::config::AgentPanelSortConfig::Priority { rows.sort_by_key(|row| { ( @@ -81,14 +169,103 @@ pub(super) fn aggregate_agent_rows( ) }); } - rows +} + +struct ClientAgentViewEntry<'a> { + endpoint_index: usize, + snapshot: &'a ClientShellSnapshot, + agent: &'a ClientShellAgent, + seen: bool, +} + +impl<'a> ClientAgentViewEntry<'a> { + fn new(row: &AggregateAgentRow<'a>) -> Self { + Self { + endpoint_index: row.endpoint.endpoint_index, + snapshot: row.endpoint.snapshot, + agent: row.agent, + seen: row.endpoint.agent_presentation.seen(row.agent), + } + } +} + +impl crate::agent_view_eval::AgentViewEntry for ClientAgentViewEntry<'_> { + fn scope(&self) -> usize { + self.endpoint_index + } + + fn status(&self) -> &'static str { + status_text(self.agent.agent_status) + } + + fn workspace_id(&self) -> Option<std::borrow::Cow<'_, str>> { + Some(std::borrow::Cow::Borrowed(&self.agent.workspace_id)) + } + + fn tab_id(&self) -> Option<std::borrow::Cow<'_, str>> { + Some(std::borrow::Cow::Borrowed(&self.agent.tab_id)) + } + + fn pane_id(&self) -> Option<std::borrow::Cow<'_, str>> { + Some(std::borrow::Cow::Borrowed(&self.agent.pane_id)) + } + + fn agent(&self) -> Option<&str> { + self.agent.agent.as_deref() + } + + fn seen(&self) -> bool { + self.seen + } + + fn state_change_seq(&self) -> Option<u64> { + Some(self.agent.state_change_seq) + } + + fn token(&self, token: &str) -> Option<&str> { + self.agent + .tokens + .iter() + .find(|(name, _)| name == token) + .map(|(_, value)| value.as_str()) + } + + fn workspace_order(&self) -> Option<u64> { + self.snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == self.agent.workspace_id) + .map(|index| index as u64) + } + + fn tab_order(&self) -> Option<u64> { + self.snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == self.agent.tab_id) + .map(|tab| tab.number as u64) + } + + fn pane_order(&self) -> Option<u64> { + let suffix = self + .agent + .pane_id + .strip_prefix(&self.agent.workspace_id)? + .strip_prefix(":p")?; + crate::workspace::decode_public_number(suffix).map(|number| number as u64) + } + + fn attention(&self) -> u64 { + u64::from(status_priority(self.agent.agent_status)) + } } pub(super) fn online_agent_targets( endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, sort: crate::config::AgentPanelSortConfig, ) -> Vec<AggregateAgentTarget> { - aggregate_agent_rows(endpoints, sort) + aggregate_agent_rows(endpoints, active_endpoint_id, sort) .into_iter() .filter(|row| !row.endpoint.stale()) .map(|row| AggregateAgentTarget { diff --git a/src/client/shell/endpoint_agent_state.rs b/src/client/shell/endpoint_agent_state.rs index fdcd7478..f8ba7a9b 100644 --- a/src/client/shell/endpoint_agent_state.rs +++ b/src/client/shell/endpoint_agent_state.rs @@ -71,14 +71,16 @@ impl EndpointAgentPresentation { changed } + pub(super) fn seen(&self, agent: &ClientShellAgent) -> bool { + self.acknowledged + .get(&agent.pane_id) + .is_some_and(|sequence| *sequence >= agent.state_change_seq) + } + fn projected_status(&self, agent: &ClientShellAgent) -> AgentStatus { match agent.agent_status { AgentStatus::Idle | AgentStatus::Done => { - if self - .acknowledged - .get(&agent.pane_id) - .is_some_and(|sequence| *sequence >= agent.state_change_seq) - { + if self.seen(agent) { AgentStatus::Idle } else { AgentStatus::Done diff --git a/src/client/shell/endpoint_agents.rs b/src/client/shell/endpoint_agents.rs index a49fccf9..3cc106b3 100644 --- a/src/client/shell/endpoint_agents.rs +++ b/src/client/shell/endpoint_agents.rs @@ -103,8 +103,17 @@ fn agent_rows( .iter() .filter_map(|endpoint| { endpoint.snapshot.as_deref().map(|snapshot| { - super::agent_sidebar::agent_rows(snapshot, config, Some(&endpoint.label)) - .into_iter() + snapshot + .agents + .iter() + .filter_map(|agent| { + super::agent_sidebar::agent_row( + snapshot, + &agent.pane_id, + config, + Some(&endpoint.label), + ) + }) .map(|agent| ((endpoint.endpoint_id.clone(), agent.pane_id.clone()), agent)) .collect::<Vec<_>>() }) @@ -112,18 +121,22 @@ fn agent_rows( .flatten() .collect::<HashMap<_, _>>(); - super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort) - .into_iter() - .filter_map(|row| { - let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); - let mut agent = rendered_rows.remove(&key)?; - agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; - Some(EndpointAgentRow { - endpoint_id: row.endpoint.endpoint_id.clone(), - machine_label: row.endpoint.label.to_owned(), - stale: row.endpoint.stale(), - agent, - }) + super::aggregate_navigation::aggregate_agent_rows( + endpoints, + active_endpoint_id, + config.agent_panel_sort, + ) + .into_iter() + .filter_map(|row| { + let key = (row.endpoint.endpoint_id.clone(), row.agent.pane_id.clone()); + let mut agent = rendered_rows.remove(&key)?; + agent.focused &= row.endpoint.endpoint_id == active_endpoint_id; + Some(EndpointAgentRow { + endpoint_id: row.endpoint.endpoint_id.clone(), + machine_label: row.endpoint.label.to_owned(), + stale: row.endpoint.stale(), + agent, }) - .collect() + }) + .collect() } diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index 4f528c3a..38ad0621 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -187,6 +187,7 @@ impl ClientShellState { ) { let agents = super::aggregate_navigation::online_agent_targets( &self.endpoints, + &self.active_endpoint_id, self.config.agent_panel_sort, ); if agents.is_empty() { diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 1b890b97..420f7131 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -1,6 +1,14 @@ use super::*; #[derive(Clone, Debug)] +pub(crate) struct ClientEndpointAgentViewProjection { + generation: Option<u64>, + boot_id: String, + revision: u64, + pub(crate) view: Result<Option<crate::api::schema::AgentViewSetParams>, ()>, +} + +#[derive(Clone, Debug)] pub(crate) struct ClientShellEndpoint { pub(crate) endpoint_id: ClientEndpointId, pub(crate) label: String, @@ -10,6 +18,9 @@ pub(crate) struct ClientShellEndpoint { pub(crate) snapshot_generation: Option<u64>, pub(crate) agent_recency: HashMap<String, u64>, pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) agent_view_projection: Option<ClientEndpointAgentViewProjection>, + pending_agent_view_projection: Option<ClientEndpointAgentViewProjection>, + pub(crate) agent_view_projection_supported: bool, pub(crate) methods: Option<HashSet<String>>, } @@ -64,6 +75,12 @@ impl ClientShellState { agent_presentation: previous .map(|endpoint| endpoint.agent_presentation.clone()) .unwrap_or_default(), + agent_view_projection: previous + .and_then(|endpoint| endpoint.agent_view_projection.clone()), + pending_agent_view_projection: previous + .and_then(|endpoint| endpoint.pending_agent_view_projection.clone()), + agent_view_projection_supported: previous + .is_some_and(|endpoint| endpoint.agent_view_projection_supported), methods: previous.and_then(|endpoint| endpoint.methods.clone()), }); } @@ -103,6 +120,9 @@ impl ClientShellState { endpoint.methods = None; endpoint.agent_recency.clear(); endpoint.agent_presentation = Default::default(); + endpoint.agent_view_projection = None; + endpoint.pending_agent_view_projection = None; + endpoint.agent_view_projection_supported = false; } } @@ -133,6 +153,24 @@ impl ClientShellState { } } + pub(crate) fn set_endpoint_agent_view_projection_supported( + &mut self, + endpoint_id: &ClientEndpointId, + supported: bool, + ) { + if let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + { + endpoint.agent_view_projection_supported = supported; + if !supported { + endpoint.agent_view_projection = None; + endpoint.pending_agent_view_projection = None; + } + } + } + pub(crate) fn set_endpoint_methods_for( &mut self, endpoint_id: &ClientEndpointId, @@ -251,6 +289,120 @@ impl ClientShellState { .map(|snapshot| (snapshot.boot_id.as_str(), snapshot.revision)) } + pub(crate) fn set_endpoint_agent_view_projection_for_generation( + &mut self, + endpoint_id: &ClientEndpointId, + generation: u64, + projection: crate::client::endpoint::DecodedAgentViewProjection, + ) { + self.set_endpoint_agent_view_projection( + endpoint_id, + Some(generation), + projection.boot_id, + projection.revision, + projection.view, + ); + } + + #[cfg(test)] + pub(crate) fn set_test_endpoint_agent_view( + &mut self, + endpoint_id: &ClientEndpointId, + view: Option<crate::api::schema::AgentViewSetParams>, + ) { + let Some((boot_id, revision)) = self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| { + endpoint + .snapshot + .as_deref() + .map(|snapshot| (snapshot.boot_id.clone(), snapshot.revision)) + }) + else { + return; + }; + self.set_endpoint_agent_view_projection_supported(endpoint_id, true); + self.set_endpoint_agent_view_projection(endpoint_id, None, boot_id, revision, Ok(view)); + } + + #[cfg(test)] + pub(crate) fn set_test_endpoint_agent_view_projection( + &mut self, + endpoint_id: &ClientEndpointId, + boot_id: &str, + revision: u64, + view: Option<crate::api::schema::AgentViewSetParams>, + ) { + self.set_endpoint_agent_view_projection( + endpoint_id, + None, + boot_id.to_owned(), + revision, + Ok(view), + ); + } + + fn set_endpoint_agent_view_projection( + &mut self, + endpoint_id: &ClientEndpointId, + generation: Option<u64>, + boot_id: String, + revision: u64, + view: Result<Option<crate::api::schema::AgentViewSetParams>, ()>, + ) { + let Some(endpoint) = self + .endpoints + .iter_mut() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + else { + return; + }; + if endpoint.snapshot_generation == generation + && endpoint + .snapshot + .as_deref() + .is_some_and(|snapshot| snapshot.boot_id == boot_id && snapshot.revision > revision) + { + return; + } + let next = ClientEndpointAgentViewProjection { + generation, + boot_id, + revision, + view, + }; + let matches_snapshot = endpoint.snapshot_generation == next.generation + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.boot_id == next.boot_id && snapshot.revision == next.revision + }); + let slot = if matches_snapshot { + &mut endpoint.agent_view_projection + } else { + &mut endpoint.pending_agent_view_projection + }; + if slot.as_ref().is_some_and(|current| { + current.generation == next.generation + && current.boot_id == next.boot_id + && current.revision >= next.revision + }) { + return; + } + *slot = Some(next); + } + + pub(crate) fn endpoint_agent_view( + endpoint: &ClientShellEndpoint, + ) -> Option<&Result<Option<crate::api::schema::AgentViewSetParams>, ()>> { + let snapshot = endpoint.snapshot.as_deref()?; + let projection = endpoint.agent_view_projection.as_ref()?; + (projection.generation == endpoint.snapshot_generation + && projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision) + .then_some(&projection.view) + } + /// A terminal normally starts focused. `None` means this host cannot report focus events, /// not that the endpoint has no viewer; activation therefore sends an explicit true baseline. pub(crate) fn host_focus_baseline(&self) -> bool { @@ -420,6 +572,35 @@ impl ClientShellState { endpoint.agent_recency = recency; endpoint.snapshot_generation = generation; endpoint.snapshot = Some(snapshot); + let pending_matches = + endpoint + .pending_agent_view_projection + .as_ref() + .is_some_and(|projection| { + projection.generation == generation + && endpoint.snapshot.as_deref().is_some_and(|snapshot| { + projection.boot_id == snapshot.boot_id + && projection.revision == snapshot.revision + }) + }); + if pending_matches { + endpoint.agent_view_projection = endpoint.pending_agent_view_projection.take(); + } else { + endpoint.pending_agent_view_projection = None; + if endpoint + .agent_view_projection + .as_ref() + .is_some_and(|projection| { + projection.generation != generation + || endpoint.snapshot.as_deref().is_some_and(|snapshot| { + projection.boot_id != snapshot.boot_id + || projection.revision != snapshot.revision + }) + }) + { + endpoint.agent_view_projection = None; + } + } } pub(crate) fn acknowledge_active_surface_agents(&mut self, surface: &PaneSurfaceFrame) -> bool { @@ -502,6 +683,9 @@ pub(super) fn local_endpoint() -> ClientShellEndpoint { snapshot_generation: None, agent_recency: HashMap::new(), agent_presentation: Default::default(), + agent_view_projection: None, + pending_agent_view_projection: None, + agent_view_projection_supported: false, methods: None, } } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 7118819b..60202d1d 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -868,6 +868,7 @@ impl ClientShellState { KeybindMatch::Action(KeybindAction::FocusAgent(index)) => { super::aggregate_navigation::online_agent_targets( &self.endpoints, + &self.active_endpoint_id, self.config.agent_panel_sort, ) .get(*index) diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 95036de5..185bef51 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -610,8 +610,11 @@ fn mobile_items( }); } } - let agents = - super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agents = super::aggregate_navigation::aggregate_agent_rows( + endpoints, + active_endpoint_id, + config.agent_panel_sort, + ); let agent_view_label = snapshot.agent_view_label.as_deref(); if !agents.is_empty() || agent_view_label.is_some() { let title = agent_view_label diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 2e5e87ac..67663bea 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -40,6 +40,24 @@ fn agent( } } +fn current_workspace_view() -> crate::api::schema::AgentViewSetParams { + use crate::api::schema::{ + AgentViewBuiltinField, AgentViewContext, AgentViewField, AgentViewFilter, AgentViewValue, + }; + + crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("current space".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId), + value: AgentViewValue::Context { + context: AgentViewContext::CurrentWorkspaceId, + }, + }), + sort: Vec::new(), + } +} + fn state_with_remote() -> (ClientShellState, ClientEndpointId) { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); let profile = remote_profile(); @@ -660,6 +678,415 @@ fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { } #[test] +fn current_workspace_agent_view_excludes_same_workspace_id_on_other_machine() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + 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.agent_view_label = Some("current space".into()); + local.agent_order = vec!["pane_1".into()]; + 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.agent_view_label = Some("current space".into()); + remote.agent_order = vec!["pane_1".into()]; + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + let view = current_workspace_view(); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view.clone())); + state.set_test_endpoint_agent_view(&endpoint_id, Some(view)); + + 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!(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("remote 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}"); +} + +#[test] +fn current_workspace_or_blocked_keeps_foreign_attention_only() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue, + }; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + 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.agent_view_label = Some("focus".into()); + 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 idle", AgentStatus::Idle, 1), + ClientShellAgent { + pane_id: "pane_2".into(), + name: Some("remote blocked".into()), + agent_status: AgentStatus::Blocked, + focused: false, + ..agent("remote blocked", AgentStatus::Blocked, 2) + }, + ]; + remote.panes.push(ClientShellPane { + pane_id: "pane_2".into(), + focused: false, + ..remote.panes[0].clone() + }); + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let mut view = current_workspace_view(); + view.label = Some("focus".into()); + view.filter = Some(AgentViewFilter::Any { + filters: vec![ + view.filter.take().expect("current workspace filter"), + AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }, + ], + }); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + 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 idle"), "frame: {text}"); + assert!(text.contains("Build · remote blocked"), "frame: {text}"); +} + +#[test] +fn selected_default_view_ignores_inactive_endpoint_projection() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewValue, + }; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + 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.agent_view_label = Some("blocked".into()); + remote.agent_order.clear(); + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, None); + state.set_test_endpoint_agent_view( + &endpoint_id, + Some(crate::api::schema::AgentViewSetParams { + source: "remote.views".into(), + label: Some("blocked".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }), + sort: Vec::new(), + }), + ); + + 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}"); +} + +#[test] +fn newer_snapshot_does_not_reuse_stale_agent_view_projection() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut local = snapshot(); + local.agent_view_label = Some("current space".into()); + state.set_snapshot(Box::new(local.clone())); + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(current_workspace_view())); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(matches!( + ClientShellState::endpoint_agent_view(endpoint), + Some(Ok(Some(_))) + )); + + state.set_test_endpoint_agent_view_projection( + &ClientEndpointId::Local, + "foreign-boot", + 99, + None, + ); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(matches!( + ClientShellState::endpoint_agent_view(endpoint), + Some(Ok(Some(_))) + )); + + local.revision += 1; + state.set_snapshot(Box::new(local)); + let endpoint = state + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id.is_local()) + .expect("local endpoint"); + assert!(ClientShellState::endpoint_agent_view(endpoint).is_none()); +} + +#[test] +fn legacy_custom_views_keep_v1_per_endpoint_projection() { + use crate::api::schema::AgentStatus; + use crate::config::AgentSidebarToken; + + let mut config = Config::default(); + 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.agent_view_label = Some("current space".into()); + local.agent_order = vec!["pane_1".into()]; + 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.agent_view_label = Some("current space".into()); + remote.agent_order = vec!["pane_1".into()]; + remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let frame = state.compose(100, 28).expect("legacy combined 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}"); +} + +#[test] +fn selected_custom_sort_orders_rendering_and_indexed_navigation() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, + AgentViewSortOrder, + }; + 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.agent_view_label = Some("recent".into()); + local.agents = vec![agent("local blocked", AgentStatus::Blocked, 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 idle", AgentStatus::Idle, 9)]; + state.set_endpoint_snapshot(&endpoint_id, Box::new(remote)); + + let mut view = current_workspace_view(); + view.label = Some("recent".into()); + view.filter = None; + view.sort = vec![AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::StateChangeSeq), + order: AgentViewSortOrder::Desc, + }]; + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + let frame = state.compose(100, 28).expect("custom sorted 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.find("Build · remote idle").expect("remote row") + < text.find("Local · local blocked").expect("local row"), + "frame: {text}" + ); + + 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: selected, + target: Some(ClientEndpointFocusTarget::Pane(pane_id)), + }] if selected == &endpoint_id && pane_id == "pane_1" + )); +} + +#[test] +fn selected_position_sort_uses_public_tab_and_pane_numbers() { + use crate::api::schema::{ + AgentStatus, AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, + AgentViewSortOrder, + }; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let mut selected = snapshot(); + selected.agent_view_label = Some("positions".into()); + + let mut tab_nine = selected.tabs[0].clone(); + tab_nine.tab_id = "ws_1:t9".into(); + tab_nine.number = 9; + let mut tab_two = tab_nine.clone(); + tab_two.tab_id = "ws_1:t2".into(); + tab_two.number = 2; + selected.tabs = vec![tab_nine, tab_two]; + + let mut pane_tab_nine = selected.panes[0].clone(); + pane_tab_nine.tab_id = "ws_1:t9".into(); + pane_tab_nine.pane_id = "ws_1:p1".into(); + let mut pane_nine = pane_tab_nine.clone(); + pane_nine.tab_id = "ws_1:t2".into(); + pane_nine.pane_id = "ws_1:p9".into(); + let mut pane_two = pane_nine.clone(); + pane_two.pane_id = "ws_1:p2".into(); + selected.panes = vec![pane_tab_nine, pane_nine, pane_two]; + + let mut late_tab = agent("tab nine", AgentStatus::Idle, 1); + late_tab.tab_id = "ws_1:t9".into(); + late_tab.pane_id = "ws_1:p1".into(); + let mut late_pane = agent("pane nine", AgentStatus::Idle, 1); + late_pane.tab_id = "ws_1:t2".into(); + late_pane.pane_id = "ws_1:p9".into(); + let mut early_pane = agent("pane two", AgentStatus::Idle, 1); + early_pane.tab_id = "ws_1:t2".into(); + early_pane.pane_id = "ws_1:p2".into(); + selected.agents = vec![late_tab, late_pane, early_pane]; + state.set_snapshot(Box::new(selected)); + + let mut view = current_workspace_view(); + view.label = Some("positions".into()); + view.filter = None; + view.sort = vec![ + AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::TabOrder), + order: AgentViewSortOrder::Asc, + }, + AgentViewSort { + field: AgentViewSortField::Builtin(AgentViewBuiltinSortField::PaneOrder), + order: AgentViewSortOrder::Asc, + }, + ]; + state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view)); + + let names = aggregate_navigation::aggregate_agent_rows( + &state.endpoints, + &state.active_endpoint_id, + crate::config::AgentPanelSortConfig::Priority, + ) + .into_iter() + .map(|row| row.agent.name.as_deref().expect("agent name")) + .collect::<Vec<_>>(); + assert_eq!(names, ["pane two", "pane nine", "tab nine"]); +} + +#[test] fn aggregate_priority_uses_client_observed_recency_across_machines() { use crate::api::schema::AgentStatus; use crate::config::AgentSidebarToken; |
