From 2f55b77d8c716a919082221d77af37d1a16a15d6 Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:16:03 +0000 Subject: [PATCH 1/2] fix: scope agent views to the selected machine refs #3732 --- .../website/src/content/docs/socket-api.mdx | 10 +- src/agent_view_eval.rs | 213 +++++++++++ src/app/agent_view.rs | 302 ++++++--------- src/client/endpoint/control.rs | 91 +++++ src/client/mod.rs | 30 ++ src/client/shell/agent_sidebar.rs | 141 +++---- src/client/shell/aggregate_navigation.rs | 212 ++++++++++- src/client/shell/endpoint_agent_state.rs | 12 +- src/client/shell/endpoint_agents.rs | 43 ++- src/client/shell/endpoint_navigation.rs | 1 + src/client/shell/endpoints.rs | 184 +++++++++ src/client/shell/input.rs | 1 + src/client/shell/mobile.rs | 7 +- src/client/shell/tests/endpoints.rs | 360 ++++++++++++++++++ src/main.rs | 1 + src/protocol/endpoint.rs | 57 +++ src/server/clients.rs | 3 + src/server/headless.rs | 19 + src/server/headless/render.rs | 49 ++- src/server/headless/tests/mod.rs | 98 +++++ 20 files changed, 1534 insertions(+), 300 deletions(-) create mode 100644 src/agent_view_eval.rs diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 13a71abb9e..f86fbf8c32 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -461,9 +461,13 @@ Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`, `agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to filter plugin-reported pane metadata. Values are strings, booleans, unsigned numbers, or a context object. Context values are `current_workspace_id` and -`current_tab_id`, and may only be compared to the matching ID field. Effective -status values are `idle`, `working`, `blocked`, `done`, and `unknown`; `done` -means idle and not yet seen. +`current_tab_id`, and may only be compared to the matching ID field. In a +saved-machine client, the selected server's view applies to the combined agent +list. Current workspace and tab context includes that selected machine, so an +identical ID on another machine does not match; independent filter branches, +such as `status` being `blocked`, still match agents on any connected machine. +Effective status values are `idle`, `working`, `blocked`, `done`, and `unknown`; +`done` means idle and not yet seen. Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`, `status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are diff --git a/src/agent_view_eval.rs b/src/agent_view_eval.rs new file mode 100644 index 0000000000..5d3fe84490 --- /dev/null +++ b/src/agent_view_eval.rs @@ -0,0 +1,213 @@ +use std::borrow::Cow; +use std::cmp::Ordering; + +use crate::api::schema::{ + AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewField, AgentViewFilter, + AgentViewSort, AgentViewSortField, AgentViewSortOrder, AgentViewValue, +}; + +pub(crate) struct AgentViewContext { + pub(crate) scope: usize, + pub(crate) workspace_id: Option, + pub(crate) tab_id: Option, +} + +pub(crate) trait AgentViewEntry { + fn scope(&self) -> usize; + fn status(&self) -> &'static str; + fn workspace_id(&self) -> Option>; + fn tab_id(&self) -> Option>; + fn pane_id(&self) -> Option>; + fn agent(&self) -> Option<&str>; + fn seen(&self) -> bool; + fn state_change_seq(&self) -> Option; + fn token(&self, token: &str) -> Option<&str>; + fn workspace_order(&self) -> Option; + fn tab_order(&self) -> Option; + fn pane_order(&self) -> Option; + fn attention(&self) -> u64; +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum EvalValue<'a> { + String(Cow<'a, str>), + ScopedId { scope: usize, value: Cow<'a, str> }, + Bool(bool), + Number(u64), +} + +pub(crate) fn matches_filter( + context: &AgentViewContext, + entry: &E, + filter: &AgentViewFilter, +) -> bool { + match filter { + AgentViewFilter::All { filters } => filters + .iter() + .all(|filter| matches_filter(context, entry, filter)), + AgentViewFilter::Any { filters } => filters + .iter() + .any(|filter| matches_filter(context, entry, filter)), + AgentViewFilter::Not { filter } => !matches_filter(context, entry, filter), + AgentViewFilter::Eq { field, value } => { + field_value(entry, field) == operand_value(context, field, value) + } + AgentViewFilter::In { field, values } => { + let actual = field_value(entry, field); + values + .iter() + .any(|value| actual == operand_value(context, field, value)) + } + AgentViewFilter::Exists { field } => field_value(entry, field).is_some(), + } +} + +pub(crate) fn compare_entries( + left: &E, + right: &E, + sorts: &[AgentViewSort], +) -> Ordering { + for sort in sorts { + let ordering = compare_optional_values( + sort_value(left, &sort.field), + sort_value(right, &sort.field), + sort.order, + ); + if ordering != Ordering::Equal { + return ordering; + } + } + Ordering::Equal +} + +fn field_value<'a, E: AgentViewEntry + ?Sized>( + entry: &'a E, + field: &AgentViewField, +) -> Option> { + match field { + AgentViewField::Builtin(AgentViewBuiltinField::Status) => { + Some(EvalValue::String(Cow::Borrowed(entry.status()))) + } + AgentViewField::Builtin(AgentViewBuiltinField::WorkspaceId) => { + entry.workspace_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::TabId) => { + entry.tab_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::PaneId) => { + entry.pane_id().map(|value| EvalValue::ScopedId { + scope: entry.scope(), + value, + }) + } + AgentViewField::Builtin(AgentViewBuiltinField::Agent) => entry + .agent() + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewField::Builtin(AgentViewBuiltinField::Seen) => Some(EvalValue::Bool(entry.seen())), + AgentViewField::Builtin(AgentViewBuiltinField::StateChangeSeq) => { + entry.state_change_seq().map(EvalValue::Number) + } + AgentViewField::Token { token } => entry + .token(token) + .map(|value| EvalValue::String(Cow::Borrowed(value))), + } +} + +fn operand_value<'a>( + context: &'a AgentViewContext, + field: &AgentViewField, + value: &'a AgentViewValue, +) -> Option> { + match value { + AgentViewValue::String(value) if is_id_field(field) => Some(EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }), + AgentViewValue::String(value) => Some(EvalValue::String(Cow::Borrowed(value))), + AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)), + AgentViewValue::Number(value) => Some(EvalValue::Number(*value)), + AgentViewValue::Context { + context: view_context, + } => match view_context { + crate::api::schema::AgentViewContext::CurrentWorkspaceId => context + .workspace_id + .as_deref() + .map(|value| EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }), + crate::api::schema::AgentViewContext::CurrentTabId => { + context.tab_id.as_deref().map(|value| EvalValue::ScopedId { + scope: context.scope, + value: Cow::Borrowed(value), + }) + } + }, + } +} + +fn is_id_field(field: &AgentViewField) -> bool { + matches!( + field, + AgentViewField::Builtin( + AgentViewBuiltinField::WorkspaceId + | AgentViewBuiltinField::TabId + | AgentViewBuiltinField::PaneId + ) + ) +} + +fn sort_value<'a, E: AgentViewEntry + ?Sized>( + entry: &'a E, + field: &AgentViewSortField, +) -> Option> { + match field { + AgentViewSortField::Token { token } => entry + .token(token) + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewSortField::Builtin(field) => match field { + AgentViewBuiltinSortField::WorkspaceOrder => { + entry.workspace_order().map(EvalValue::Number) + } + AgentViewBuiltinSortField::TabOrder => entry.tab_order().map(EvalValue::Number), + AgentViewBuiltinSortField::PaneOrder => entry.pane_order().map(EvalValue::Number), + AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(entry.attention())), + AgentViewBuiltinSortField::Status => { + Some(EvalValue::String(Cow::Borrowed(entry.status()))) + } + AgentViewBuiltinSortField::Agent => entry + .agent() + .map(|value| EvalValue::String(Cow::Borrowed(value))), + AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen())), + AgentViewBuiltinSortField::StateChangeSeq => { + entry.state_change_seq().map(EvalValue::Number) + } + }, + } +} + +fn compare_optional_values( + left: Option>, + right: Option>, + order: AgentViewSortOrder, +) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => { + let ordering = left.cmp(&right); + if matches!(order, AgentViewSortOrder::Desc) { + ordering.reverse() + } else { + ordering + } + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} diff --git a/src/app/agent_view.rs b/src/app/agent_view.rs index 58c1d7defd..e10d638a38 100644 --- a/src/app/agent_view.rs +++ b/src/app/agent_view.rs @@ -1,9 +1,9 @@ -use std::cmp::Ordering; +use std::borrow::Cow; +use crate::agent_view_eval::{AgentViewContext as EvaluationContext, AgentViewEntry}; use crate::api::schema::{ - AgentStatus, AgentViewBuiltinField, AgentViewBuiltinSortField, AgentViewContext, - AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewSort, AgentViewSortField, - AgentViewSortOrder, AgentViewValue, + AgentViewBuiltinField, AgentViewContext, AgentViewField, AgentViewFilter, AgentViewSetParams, + AgentViewSortField, AgentViewValue, }; use crate::ui::AgentPanelEntry; @@ -16,13 +16,6 @@ const MAX_SORT_FIELDS: usize = 8; const MAX_SOURCE_CHARS: usize = 120; const MAX_LABEL_CHARS: usize = 32; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum EvalValue { - String(String), - Bool(bool), - Number(u64), -} - pub(crate) fn validate_agent_view(spec: &mut AgentViewSetParams) -> Result<(), String> { spec.source = normalize_source(&spec.source)?; spec.label = spec @@ -52,11 +45,24 @@ pub(crate) fn validate_agent_view_source(source: &str) -> Result pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec) { if let Some(spec) = app.agent_view_override.as_ref() { + let context = evaluation_context(app); if let Some(filter) = &spec.filter { - entries.retain(|entry| matches_filter(app, entry, filter)); + entries.retain(|entry| { + crate::agent_view_eval::matches_filter( + &context, + &AppAgentViewEntry { app, entry }, + filter, + ) + }); } if !spec.sort.is_empty() { - entries.sort_by(|left, right| compare_entries(app, left, right, &spec.sort)); + entries.sort_by(|left, right| { + crate::agent_view_eval::compare_entries( + &AppAgentViewEntry { app, entry: left }, + &AppAgentViewEntry { app, entry: right }, + &spec.sort, + ) + }); return; } } @@ -81,6 +87,94 @@ pub(crate) fn presented_workspace_idx(app: &AppState) -> Option { app.active } +fn evaluation_context(app: &AppState) -> EvaluationContext { + let workspace = presented_workspace_idx(app).and_then(|index| app.workspaces.get(index)); + EvaluationContext { + scope: 0, + workspace_id: workspace.map(|workspace| workspace.id.clone()), + tab_id: workspace.and_then(|workspace| { + let number = workspace.public_tab_number(workspace.active_tab)?; + Some(crate::workspace::public_tab_id_for_number( + &workspace.id, + number, + )) + }), + } +} + +struct AppAgentViewEntry<'a> { + app: &'a AppState, + entry: &'a AgentPanelEntry, +} + +impl AgentViewEntry for AppAgentViewEntry<'_> { + fn scope(&self) -> usize { + 0 + } + + fn status(&self) -> &'static str { + status_name(self.entry.state, self.entry.seen) + } + + fn workspace_id(&self) -> Option> { + self.app + .workspaces + .get(self.entry.ws_idx) + .map(|workspace| Cow::Borrowed(workspace.id.as_str())) + } + + fn tab_id(&self) -> Option> { + public_tab_id(self.app, self.entry).map(Cow::Owned) + } + + fn pane_id(&self) -> Option> { + public_pane_id(self.app, self.entry).map(Cow::Owned) + } + + fn agent(&self) -> Option<&str> { + self.entry.agent_kind_label.as_deref() + } + + fn seen(&self) -> bool { + self.entry.seen + } + + fn state_change_seq(&self) -> Option { + self.entry.last_agent_state_change_seq + } + + fn token(&self, token: &str) -> Option<&str> { + self.entry.tokens.get(token).map(String::as_str) + } + + fn workspace_order(&self) -> Option { + Some(self.entry.ws_idx as u64) + } + + fn tab_order(&self) -> Option { + self.app + .workspaces + .get(self.entry.ws_idx) + .and_then(|workspace| workspace.public_tab_number(self.entry.tab_idx)) + .map(|number| number as u64) + } + + fn pane_order(&self) -> Option { + self.app + .workspaces + .get(self.entry.ws_idx) + .and_then(|workspace| workspace.public_pane_number(self.entry.pane_id)) + .map(|number| number as u64) + } + + fn attention(&self) -> u64 { + u64::from(super::api_helpers::tab_attention_priority( + self.entry.state, + self.entry.seen, + )) + } +} + fn normalize_source(source: &str) -> Result { let source = source.trim(); if source.is_empty() @@ -228,180 +322,16 @@ fn validate_token(token: &str) -> Result<(), String> { Ok(()) } -fn matches_filter(app: &AppState, entry: &AgentPanelEntry, filter: &AgentViewFilter) -> bool { - match filter { - AgentViewFilter::All { filters } => filters - .iter() - .all(|filter| matches_filter(app, entry, filter)), - AgentViewFilter::Any { filters } => filters - .iter() - .any(|filter| matches_filter(app, entry, filter)), - AgentViewFilter::Not { filter } => !matches_filter(app, entry, filter), - AgentViewFilter::Eq { field, value } => { - field_value(app, entry, field) == operand_value(app, value) - } - AgentViewFilter::In { field, values } => { - let actual = field_value(app, entry, field); - values - .iter() - .any(|value| actual == operand_value(app, value)) - } - AgentViewFilter::Exists { field } => field_value(app, entry, field).is_some(), - } -} - -fn compare_entries( - app: &AppState, - left: &AgentPanelEntry, - right: &AgentPanelEntry, - sorts: &[AgentViewSort], -) -> Ordering { - for sort in sorts { - let left = sort_value(app, left, &sort.field); - let right = sort_value(app, right, &sort.field); - let ordering = compare_optional_values(left, right, sort.order); - if ordering != Ordering::Equal { - return ordering; - } - } - Ordering::Equal -} - -fn compare_optional_values( - left: Option, - right: Option, - order: AgentViewSortOrder, -) -> Ordering { - match (left, right) { - (Some(left), Some(right)) => { - let ordering = left.cmp(&right); - if matches!(order, AgentViewSortOrder::Desc) { - ordering.reverse() - } else { - ordering - } - } - (Some(_), None) => Ordering::Less, - (None, Some(_)) => Ordering::Greater, - (None, None) => Ordering::Equal, - } -} - -fn field_value( - app: &AppState, - entry: &AgentPanelEntry, - field: &AgentViewField, -) -> Option { - match field { - AgentViewField::Builtin(field) => builtin_field_value(app, entry, *field), - AgentViewField::Token { token } => entry.tokens.get(token).cloned().map(EvalValue::String), - } -} - -fn builtin_field_value( - app: &AppState, - entry: &AgentPanelEntry, - field: AgentViewBuiltinField, -) -> Option { - match field { - AgentViewBuiltinField::Status => { - Some(EvalValue::String(status_name(entry.state, entry.seen))) - } - AgentViewBuiltinField::WorkspaceId => app - .workspaces - .get(entry.ws_idx) - .map(|workspace| EvalValue::String(workspace.id.clone())), - AgentViewBuiltinField::TabId => public_tab_id(app, entry).map(EvalValue::String), - AgentViewBuiltinField::PaneId => public_pane_id(app, entry).map(EvalValue::String), - AgentViewBuiltinField::Agent => entry.agent_kind_label.clone().map(EvalValue::String), - AgentViewBuiltinField::Seen => Some(EvalValue::Bool(entry.seen)), - AgentViewBuiltinField::StateChangeSeq => { - entry.last_agent_state_change_seq.map(EvalValue::Number) - } - } -} - -fn operand_value(app: &AppState, value: &AgentViewValue) -> Option { - match value { - AgentViewValue::String(value) => Some(EvalValue::String(value.clone())), - AgentViewValue::Bool(value) => Some(EvalValue::Bool(*value)), - AgentViewValue::Number(value) => Some(EvalValue::Number(*value)), - AgentViewValue::Context { context } => context_value(app, *context), - } -} - -fn context_value(app: &AppState, context: AgentViewContext) -> Option { - let ws_idx = presented_workspace_idx(app)?; - let workspace = app.workspaces.get(ws_idx)?; - match context { - AgentViewContext::CurrentWorkspaceId => Some(EvalValue::String(workspace.id.clone())), - AgentViewContext::CurrentTabId => { - let tab_number = workspace.public_tab_number(workspace.active_tab)?; - Some(EvalValue::String( - crate::workspace::public_tab_id_for_number(&workspace.id, tab_number), - )) - } - } -} - -fn sort_value( - app: &AppState, - entry: &AgentPanelEntry, - field: &AgentViewSortField, -) -> Option { - match field { - AgentViewSortField::Token { token } => { - entry.tokens.get(token).cloned().map(EvalValue::String) - } - AgentViewSortField::Builtin(field) => match field { - AgentViewBuiltinSortField::WorkspaceOrder => { - Some(EvalValue::Number(entry.ws_idx as u64)) - } - AgentViewBuiltinSortField::TabOrder => app - .workspaces - .get(entry.ws_idx) - .and_then(|workspace| workspace.public_tab_number(entry.tab_idx)) - .map(|number| EvalValue::Number(number as u64)), - AgentViewBuiltinSortField::PaneOrder => app - .workspaces - .get(entry.ws_idx) - .and_then(|workspace| workspace.public_pane_number(entry.pane_id)) - .map(|number| EvalValue::Number(number as u64)), - AgentViewBuiltinSortField::Attention => Some(EvalValue::Number(u64::from( - super::api_helpers::tab_attention_priority(entry.state, entry.seen), - ))), - AgentViewBuiltinSortField::Status => { - Some(EvalValue::String(status_name(entry.state, entry.seen))) - } - AgentViewBuiltinSortField::Agent => { - entry.agent_kind_label.clone().map(EvalValue::String) - } - AgentViewBuiltinSortField::Seen => Some(EvalValue::Bool(entry.seen)), - AgentViewBuiltinSortField::StateChangeSeq => { - entry.last_agent_state_change_seq.map(EvalValue::Number) - } - }, +fn status_name(state: crate::detect::AgentState, seen: bool) -> &'static str { + match (state, seen) { + (crate::detect::AgentState::Idle, false) => "done", + (crate::detect::AgentState::Idle, true) => "idle", + (crate::detect::AgentState::Working, _) => "working", + (crate::detect::AgentState::Blocked, _) => "blocked", + (crate::detect::AgentState::Unknown, _) => "unknown", } } -fn status_name(state: crate::detect::AgentState, seen: bool) -> String { - let status = match (state, seen) { - (crate::detect::AgentState::Idle, false) => AgentStatus::Done, - (crate::detect::AgentState::Idle, true) => AgentStatus::Idle, - (crate::detect::AgentState::Working, _) => AgentStatus::Working, - (crate::detect::AgentState::Blocked, _) => AgentStatus::Blocked, - (crate::detect::AgentState::Unknown, _) => AgentStatus::Unknown, - }; - match status { - AgentStatus::Idle => "idle", - AgentStatus::Working => "working", - AgentStatus::Blocked => "blocked", - AgentStatus::Done => "done", - AgentStatus::Unknown => "unknown", - } - .to_string() -} - fn public_tab_id(app: &AppState, entry: &AgentPanelEntry) -> Option { let workspace = app.workspaces.get(entry.ws_idx)?; let number = workspace.public_tab_number(entry.tab_idx)?; @@ -423,7 +353,9 @@ fn public_pane_id(app: &AppState, entry: &AgentPanelEntry) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::api::schema::{AgentViewBuiltinSortField, AgentViewSortField}; + use crate::api::schema::{ + AgentViewBuiltinSortField, AgentViewSort, AgentViewSortField, AgentViewSortOrder, + }; use crate::detect::{Agent, AgentState}; use crate::workspace::Workspace; diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs index 7cf1159f57..6eef8dc8e3 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -1,7 +1,14 @@ use super::ClientEndpointId; +pub(crate) struct DecodedAgentViewProjection { + pub(crate) boot_id: String, + pub(crate) revision: u64, + pub(crate) view: Result, ()>, +} + pub(crate) enum EndpointControlMessage { HealthPong, + AgentViewProjection(DecodedAgentViewProjection), Snapshot(Box), Ignored, } @@ -13,6 +20,33 @@ pub(crate) fn decode_endpoint_control( if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { return Ok(EndpointControlMessage::HealthPong); } + if kind == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND { + let Ok(projection): Result = + serde_json::from_str(data) + else { + return Ok(EndpointControlMessage::Ignored); + }; + let view = match projection.view { + Some(value) => serde_json::from_value(value) + .map(Some) + .map_err(|_| ()) + .and_then(|mut view| { + view.as_mut() + .map(crate::app::agent_view::validate_agent_view) + .transpose() + .map(|_| view) + .map_err(|_| ()) + }), + None => Ok(None), + }; + return Ok(EndpointControlMessage::AgentViewProjection( + DecodedAgentViewProjection { + boot_id: projection.boot_id, + revision: projection.revision, + view, + }, + )); + } if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { let snapshot = serde_json::from_str(data) .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; @@ -43,6 +77,63 @@ mod tests { )); } + #[test] + fn agent_view_projection_decodes_and_validates_the_view() { + let view = crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: None, + sort: Vec::new(), + }; + let crate::protocol::ServerMessage::EndpointControl { kind, data } = + crate::protocol::endpoint::agent_view_projection_message("boot", 4, Some(&view)) + .unwrap() + else { + panic!("projection control"); + }; + let EndpointControlMessage::AgentViewProjection(decoded) = + decode_endpoint_control(&kind, &data).unwrap() + else { + panic!("decoded projection"); + }; + assert_eq!(decoded.boot_id, "boot"); + assert_eq!(decoded.revision, 4); + assert_eq!(decoded.view, Ok(Some(view))); + } + + #[test] + fn unsupported_agent_view_payload_falls_back_without_rejecting_endpoint() { + let projection = crate::protocol::endpoint::EndpointAgentViewProjection { + boot_id: "boot".into(), + revision: 5, + view: Some(serde_json::json!({ + "source": "example.views", + "filter": {"op": "future_filter"} + })), + }; + let decoded = decode_endpoint_control( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND, + &serde_json::to_string(&projection).unwrap(), + ) + .unwrap(); + let EndpointControlMessage::AgentViewProjection(decoded) = decoded else { + panic!("decoded projection"); + }; + assert_eq!(decoded.view, Err(())); + } + + #[test] + fn malformed_agent_view_envelope_is_ignored() { + assert!(matches!( + decode_endpoint_control( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND, + "not json" + ) + .unwrap(), + EndpointControlMessage::Ignored + )); + } + #[test] fn unknown_snapshot_codecs_are_rejected() { assert_eq!( diff --git a/src/client/mod.rs b/src/client/mod.rs index b815a057e7..e1d9eae018 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -417,6 +417,17 @@ async fn run_client_loop( .as_ref() .and_then(|(_, handshake)| handshake.endpoint_methods.clone()), ); + shell.set_endpoint_agent_view_projection_supported( + &endpoint::ClientEndpointId::Local, + initial + .as_ref() + .and_then(|(_, handshake)| handshake.endpoint_capabilities.as_ref()) + .is_some_and(|capabilities| { + capabilities.iter().any(|capability| { + capability == crate::protocol::endpoint::AGENT_VIEW_PROJECTION_CAPABILITY + }) + }), + ); if local_unavailable { shell.set_endpoint_status( &endpoint::ClientEndpointId::Local, @@ -1153,8 +1164,15 @@ async fn run_client_loop( ) { continue; } + let agent_view_projection_supported = negotiation.supports_capability( + crate::protocol::endpoint::AGENT_VIEW_PROJECTION_CAPABILITY, + ); let frame = state.shell.as_mut().and_then(|shell| { shell.set_endpoint_methods_for(&endpoint_id, Some(negotiation.methods())); + shell.set_endpoint_agent_view_projection_supported( + &endpoint_id, + agent_view_projection_supported, + ); shell.compose(state.reported_size.0, state.reported_size.1) }); let reader_quit = writer.stop_handle(); @@ -1825,6 +1843,18 @@ async fn run_client_loop( } let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::AgentViewProjection( + projection, + )) => { + if let Some(shell) = state.shell.as_mut() { + shell.set_endpoint_agent_view_projection_for_generation( + &endpoint_id, + generation, + projection, + ); + } + continue; + } Ok(endpoint::EndpointControlMessage::Ignored) => { debug!(%kind, "ignoring unknown endpoint control message"); continue; diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index d6fe33529b..3cdb725cf3 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 { 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::>(); - let tokens = agent.tokens.iter().cloned().collect::>(); - 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 { + 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::>(); + let tokens = agent.tokens.iter().cloned().collect::>(); + 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 5c826bf375..8bd77b1666 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, + 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> { - 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> { +) -> Vec> { + 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::>(); + 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::>(); + 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,104 @@ 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> { + Some(std::borrow::Cow::Borrowed(&self.agent.workspace_id)) + } + + fn tab_id(&self) -> Option> { + Some(std::borrow::Cow::Borrowed(&self.agent.tab_id)) + } + + fn pane_id(&self) -> Option> { + 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 { + 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 { + self.snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == self.agent.workspace_id) + .map(|index| index as u64) + } + + fn tab_order(&self) -> Option { + self.snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == self.agent.workspace_id) + .position(|tab| tab.tab_id == self.agent.tab_id) + .map(|index| index as u64) + } + + fn pane_order(&self) -> Option { + self.snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == self.agent.tab_id) + .position(|pane| pane.pane_id == self.agent.pane_id) + .map(|index| index 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 { - 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 fdcd747863..f8ba7a9bd6 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 a49fccf983..3cc106b36a 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::>() }) @@ -112,18 +121,22 @@ fn agent_rows( .flatten() .collect::>(); - 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 4f528c3a27..38ad06218f 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 1b890b97be..420f713102 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -1,5 +1,13 @@ use super::*; +#[derive(Clone, Debug)] +pub(crate) struct ClientEndpointAgentViewProjection { + generation: Option, + boot_id: String, + revision: u64, + pub(crate) view: Result, ()>, +} + #[derive(Clone, Debug)] pub(crate) struct ClientShellEndpoint { pub(crate) endpoint_id: ClientEndpointId, @@ -10,6 +18,9 @@ pub(crate) struct ClientShellEndpoint { pub(crate) snapshot_generation: Option, pub(crate) agent_recency: HashMap, pub(super) agent_presentation: super::endpoint_agent_state::EndpointAgentPresentation, + pub(crate) agent_view_projection: Option, + pending_agent_view_projection: Option, + pub(crate) agent_view_projection_supported: bool, pub(crate) methods: Option>, } @@ -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, + ) { + 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, + ) { + 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, + boot_id: String, + revision: u64, + view: Result, ()>, + ) { + 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, ()>> { + 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 9b5fff21b0..63a012188b 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -853,6 +853,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 abe50590f2..5ff6c84fc6 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 515c983736..1f93f6ad4a 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -37,6 +37,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(); @@ -547,6 +565,348 @@ fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() { .any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red)); } +#[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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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 aggregate_priority_uses_client_observed_recency_across_machines() { use crate::api::schema::AgentStatus; diff --git a/src/main.rs b/src/main.rs index cf7cf5b481..3346cf5496 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ ]; mod agent_resume; +mod agent_view_eval; mod api; mod app; mod build_info; diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index a70ef568b1..640e594e44 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -27,6 +27,8 @@ pub const PRESENTATION_EFFECTS_READY_KIND: &str = "endpoint.presentation.ready.v pub const HEALTH_CHECK_CAPABILITY: &str = "health_check"; pub const HEALTH_PING_KIND: &str = "endpoint.health.ping.v1"; pub const HEALTH_PONG_KIND: &str = "endpoint.health.pong.v1"; +pub const AGENT_VIEW_PROJECTION_CAPABILITY: &str = "agent_view_projection"; +pub const AGENT_VIEW_PROJECTION_KIND: &str = "endpoint.agent-view.v1"; fn default_true() -> bool { true @@ -60,6 +62,16 @@ pub struct EndpointHandshakeError { pub message: String, } +/// Optional companion to a V1 snapshot. The view payload is intentionally opaque to this +/// stable envelope so clients can ignore view language additions they do not understand. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointAgentViewProjection { + pub boot_id: String, + pub revision: u64, + #[serde(default)] + pub view: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointServerWelcome { pub generation: u32, @@ -83,6 +95,22 @@ pub fn snapshot_message(snapshot: &ClientShellSnapshot) -> serde_json::Result, +) -> serde_json::Result { + let projection = EndpointAgentViewProjection { + boot_id: boot_id.to_owned(), + revision, + view: view.map(serde_json::to_value).transpose()?, + }; + Ok(ServerMessage::EndpointControl { + kind: AGENT_VIEW_PROJECTION_KIND.into(), + data: serde_json::to_string(&projection)?, + }) +} + impl EndpointClientHello { pub fn supports_required_codecs(&self) -> bool { self.snapshot_codecs @@ -114,6 +142,7 @@ impl EndpointServerWelcome { SURFACE_INTEREST_CAPABILITY.into(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), + AGENT_VIEW_PROJECTION_CAPABILITY.into(), ], error: None, } @@ -243,6 +272,33 @@ mod tests { assert_eq!(decoded, snapshot); } + #[test] + fn agent_view_projection_is_an_optional_revision_bound_control() { + let view = crate::api::schema::AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: None, + sort: Vec::new(), + }; + let ServerMessage::EndpointControl { kind, data } = + agent_view_projection_message("boot", 7, Some(&view)).unwrap() + else { + panic!("projection should use endpoint control"); + }; + assert_eq!(kind, AGENT_VIEW_PROJECTION_KIND); + let projection: EndpointAgentViewProjection = serde_json::from_str(&data).unwrap(); + assert_eq!(projection.boot_id, "boot"); + assert_eq!(projection.revision, 7); + assert_eq!( + projection + .view + .map(serde_json::from_value) + .transpose() + .unwrap(), + Some(view) + ); + } + #[test] fn snapshot_json_tolerates_future_fields_and_command_actions() { let mut snapshot = match snapshot_message(&snapshot()).unwrap() { @@ -284,6 +340,7 @@ mod tests { SURFACE_INTEREST_CAPABILITY.to_string(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), + AGENT_VIEW_PROJECTION_CAPABILITY.to_string(), ] ); } diff --git a/src/server/clients.rs b/src/server/clients.rs index 972342ac9b..c2b419f409 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -177,6 +177,8 @@ pub(crate) struct ClientConnection { pub(crate) shell_location: Option, /// Last coherent shell replacement sent to this client. pub(crate) shell_snapshot: Option, + /// View policy paired with the last coherent shell replacement. + pub(crate) shell_agent_view: Option, /// Monotonic shell replacement revision for this connection. pub(crate) shell_projection_revision: u64, /// Whether this shell is waiting for one ordered endpoint command response. @@ -244,6 +246,7 @@ impl ClientConnection { staged_clipboard_files: Vec::new(), shell_location: None, shell_snapshot: None, + shell_agent_view: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, shell_endpoint_command_surface_revision: None, diff --git a/src/server/headless.rs b/src/server/headless.rs index 2d65237834..beda84b519 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2006,6 +2006,21 @@ impl HeadlessServer { ); let location = crate::server::clients::ClientShellLocation::from_snapshot(&seed_snapshot); + let agent_view = self.app.state.agent_view_override.clone(); + let projection_message = match agent_view.as_ref() { + Some(view) => match crate::protocol::endpoint::agent_view_projection_message( + &seed_snapshot.boot_id, + seed_snapshot.revision, + Some(view), + ) { + Ok(message) => Some(message), + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint agent view"); + return false; + } + }, + None => None, + }; let snapshot_message = match crate::protocol::endpoint::snapshot_message(&seed_snapshot) { Ok(message) => message, @@ -2016,10 +2031,14 @@ impl HeadlessServer { }; connection.shell_location = Some(location); connection.shell_snapshot = Some(seed_snapshot); + connection.shell_agent_view = agent_view; self.clients.insert(client_id, connection); if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() { self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id); } + if let Some(message) = projection_message { + self.send_to_client(client_id, message); + } self.send_to_client(client_id, snapshot_message); if surface_active { self.foreground_client_id = Some(client_id); diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 5e36be0119..2b2713dac9 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -417,6 +417,7 @@ impl HeadlessServer { .clients .get(&client_id) .and_then(|client| client.shell_location.clone()); + let agent_view = self.app.state.agent_view_override.clone(); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; @@ -433,19 +434,52 @@ impl HeadlessServer { self.server_config_diagnostic_without_keybindings.clone() }; candidate.revision = client.shell_projection_revision; - if client.shell_snapshot.as_ref() != Some(&candidate) { + if client.shell_snapshot.as_ref() != Some(&candidate) + || client.shell_agent_view != agent_view + { client.shell_projection_revision = client.shell_projection_revision.saturating_add(1); candidate.revision = client.shell_projection_revision; - let message = match crate::protocol::endpoint::snapshot_message(&candidate) { - Ok(message) => message, + let projection_message = if agent_view.is_some() + || client.shell_agent_view.is_some() + { + match crate::protocol::endpoint::agent_view_projection_message( + &candidate.boot_id, + candidate.revision, + agent_view.as_ref(), + ) { + Ok(message) => Some(message), + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint agent view"); + broken_clients.push(client_id); + continue; + } + } + } else { + None + }; + let snapshot_message = + match crate::protocol::endpoint::snapshot_message(&candidate) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint snapshot"); + broken_clients.push(client_id); + continue; + } + }; + let projection_framed = match projection_message + .as_ref() + .map(Self::frame_server_message) + .transpose() + { + Ok(framed) => framed, Err(err) => { - warn!(client_id, err = %err, "failed to encode endpoint snapshot"); + warn!(client_id, err = %err, "failed to frame endpoint agent view"); broken_clients.push(client_id); continue; } }; - let framed = match Self::frame_server_message(&message) { + let snapshot_framed = match Self::frame_server_message(&snapshot_message) { Ok(framed) => framed, Err(err) => { warn!(client_id, err = %err, "failed to frame endpoint snapshot"); @@ -457,11 +491,14 @@ impl HeadlessServer { broken_clients.push(client_id); continue; }; - if writer.control.send(framed).is_err() { + if projection_framed.is_some_and(|framed| writer.control.send(framed).is_err()) + || writer.control.send(snapshot_framed).is_err() + { broken_clients.push(client_id); continue; } client.shell_snapshot = Some(candidate); + client.shell_agent_view = agent_view; } shell_projection_revision = client.shell_projection_revision; if !client.shell_surface_active { diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 63f7264dc3..7c147e7330 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -13,6 +13,16 @@ fn client_shell_snapshot(message: ServerMessage) -> Box crate::protocol::endpoint::EndpointAgentViewProjection { + let ServerMessage::EndpointControl { kind, data } = message else { + panic!("expected client agent view projection"); + }; + assert_eq!(kind, crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND); + serde_json::from_str(&data).expect("decode client agent view projection") +} + fn test_headless_server() -> HeadlessServer { test_headless_server_with_event_hub(api::EventHub::default()) } @@ -736,6 +746,94 @@ fn terminal_client_endpoint_request_error_removes_client() { assert!(!server.clients.contains_key(&client_id)); } +#[tokio::test] +async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots() { + use crate::api::schema::{ + AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewValue, + }; + + let mut server = test_headless_server(); + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + client_id: 77, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: false, + surface_active: false, + writer, + }) + ); + let initial = client_shell_snapshot(read_server_message( + control_rx.recv().expect("initial snapshot"), + )); + + let mut view = AgentViewSetParams { + source: "example.views".into(), + label: Some("focus".into()), + filter: Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("working".into()), + }), + sort: Vec::new(), + }; + server.app.state.agent_view_override = Some(view.clone()); + server.render_and_stream(); + let set = client_agent_view_projection(read_server_message( + control_rx.recv().expect("set projection"), + )); + let set_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("set snapshot"), + )); + assert!(set.revision > initial.revision); + assert_eq!(set.revision, set_snapshot.revision); + assert_eq!( + set.view.map(serde_json::from_value).transpose().unwrap(), + Some(view.clone()) + ); + + view.filter = Some(AgentViewFilter::Eq { + field: AgentViewField::Builtin(AgentViewBuiltinField::Status), + value: AgentViewValue::String("blocked".into()), + }); + server.app.state.agent_view_override = Some(view.clone()); + server.render_and_stream(); + let replacement = client_agent_view_projection(read_server_message( + control_rx.recv().expect("replacement projection"), + )); + let replacement_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("replacement snapshot"), + )); + assert!(replacement.revision > set.revision); + assert_eq!(replacement.revision, replacement_snapshot.revision); + assert_eq!( + replacement + .view + .map(serde_json::from_value) + .transpose() + .unwrap(), + Some(view) + ); + + server.app.state.agent_view_override = None; + server.render_and_stream(); + let cleared = client_agent_view_projection(read_server_message( + control_rx.recv().expect("clear projection"), + )); + let cleared_snapshot = client_shell_snapshot(read_server_message( + control_rx.recv().expect("clear snapshot"), + )); + assert!(cleared.revision > replacement.revision); + assert_eq!(cleared.revision, cleared_snapshot.revision); + assert!(cleared.view.is_none()); + assert!(cleared_snapshot.agent_view_label.is_none()); +} + #[tokio::test] async fn client_shell_receives_metadata_then_shell_free_pane_surface() { let mut server = test_headless_server(); From f22259205db5071f1bc18c30128d933cea427632 Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:33:04 +0000 Subject: [PATCH 2/2] fix: preserve stable agent view ordering refs #3732 --- src/client/shell/aggregate_navigation.rs | 17 +++--- src/client/shell/tests/endpoints.rs | 67 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index 8bd77b1666..c0195f9352 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -242,18 +242,17 @@ impl crate::agent_view_eval::AgentViewEntry for ClientAgentViewEntry<'_> { self.snapshot .tabs .iter() - .filter(|tab| tab.workspace_id == self.agent.workspace_id) - .position(|tab| tab.tab_id == self.agent.tab_id) - .map(|index| index as u64) + .find(|tab| tab.tab_id == self.agent.tab_id) + .map(|tab| tab.number as u64) } fn pane_order(&self) -> Option { - self.snapshot - .panes - .iter() - .filter(|pane| pane.tab_id == self.agent.tab_id) - .position(|pane| pane.pane_id == self.agent.pane_id) - .map(|index| index as 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 { diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 1f93f6ad4a..2513539d65 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -907,6 +907,73 @@ fn selected_custom_sort_orders_rendering_and_indexed_navigation() { )); } +#[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::>(); + assert_eq!(names, ["pane two", "pane nine", "tab nine"]); +} + #[test] fn aggregate_priority_uses_client_observed_recency_across_machines() { use crate::api::schema::AgentStatus;