aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/endpoint/control.rs91
-rw-r--r--src/client/mod.rs30
-rw-r--r--src/client/shell/agent_sidebar.rs141
-rw-r--r--src/client/shell/aggregate_navigation.rs211
-rw-r--r--src/client/shell/endpoint_agent_state.rs12
-rw-r--r--src/client/shell/endpoint_agents.rs43
-rw-r--r--src/client/shell/endpoint_navigation.rs1
-rw-r--r--src/client/shell/endpoints.rs184
-rw-r--r--src/client/shell/input.rs1
-rw-r--r--src/client/shell/mobile.rs7
-rw-r--r--src/client/shell/tests/endpoints.rs427
11 files changed, 1042 insertions, 106 deletions
diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs
index 7cf1159f..6eef8dc8 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<Option<crate::api::schema::AgentViewSetParams>, ()>,
+}
+
pub(crate) enum EndpointControlMessage {
HealthPong,
+ AgentViewProjection(DecodedAgentViewProjection),
Snapshot(Box<crate::protocol::ClientShellSnapshot>),
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<crate::protocol::endpoint::EndpointAgentViewProjection, _> =
+ 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}"))?;
@@ -44,6 +78,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!(
decode_endpoint_control("shell.snapshot.v2", "{}")
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 36174064..8775ac19 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -421,6 +421,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,
@@ -1157,8 +1168,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();
@@ -1829,6 +1847,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 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;