aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/agent_view_eval.rs213
-rw-r--r--src/app/agent_view.rs302
-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
-rw-r--r--src/main.rs1
-rw-r--r--src/protocol/endpoint.rs57
-rw-r--r--src/server/clients.rs3
-rw-r--r--src/server/headless.rs19
-rw-r--r--src/server/headless/render.rs49
-rw-r--r--src/server/headless/tests/mod.rs98
19 files changed, 1593 insertions, 297 deletions
diff --git a/src/agent_view_eval.rs b/src/agent_view_eval.rs
new file mode 100644
index 00000000..5d3fe844
--- /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<String>,
+ pub(crate) tab_id: Option<String>,
+}
+
+pub(crate) trait AgentViewEntry {
+ fn scope(&self) -> usize;
+ fn status(&self) -> &'static str;
+ fn workspace_id(&self) -> Option<Cow<'_, str>>;
+ fn tab_id(&self) -> Option<Cow<'_, str>>;
+ fn pane_id(&self) -> Option<Cow<'_, str>>;
+ fn agent(&self) -> Option<&str>;
+ fn seen(&self) -> bool;
+ fn state_change_seq(&self) -> Option<u64>;
+ fn token(&self, token: &str) -> Option<&str>;
+ fn workspace_order(&self) -> Option<u64>;
+ fn tab_order(&self) -> Option<u64>;
+ fn pane_order(&self) -> Option<u64>;
+ 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<E: AgentViewEntry + ?Sized>(
+ 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<E: AgentViewEntry + ?Sized>(
+ 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<EvalValue<'a>> {
+ 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<EvalValue<'a>> {
+ 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<EvalValue<'a>> {
+ 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<EvalValue<'_>>,
+ right: Option<EvalValue<'_>>,
+ 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 58c1d7de..e10d638a 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<String, String>
pub(crate) fn apply_agent_view(app: &AppState, entries: &mut Vec<AgentPanelEntry>) {
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<usize> {
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<Cow<'_, str>> {
+ self.app
+ .workspaces
+ .get(self.entry.ws_idx)
+ .map(|workspace| Cow::Borrowed(workspace.id.as_str()))
+ }
+
+ fn tab_id(&self) -> Option<Cow<'_, str>> {
+ public_tab_id(self.app, self.entry).map(Cow::Owned)
+ }
+
+ fn pane_id(&self) -> Option<Cow<'_, str>> {
+ 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<u64> {
+ 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<u64> {
+ Some(self.entry.ws_idx as u64)
+ }
+
+ fn tab_order(&self) -> Option<u64> {
+ 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<u64> {
+ 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<String, String> {
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<EvalValue>,
- right: Option<EvalValue>,
- 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<EvalValue> {
- 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<EvalValue> {
- 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<EvalValue> {
- 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<EvalValue> {
- 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<EvalValue> {
- 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<String> {
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<String> {
#[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 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;
diff --git a/src/main.rs b/src/main.rs
index c75b2e7b..848c4e43 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 a70ef568..640e594e 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<serde_json::Value>,
+}
+
#[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<Se
})
}
+pub fn agent_view_projection_message(
+ boot_id: &str,
+ revision: u64,
+ view: Option<&crate::api::schema::AgentViewSetParams>,
+) -> serde_json::Result<ServerMessage> {
+ 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,
}
@@ -244,6 +273,33 @@ mod tests {
}
#[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() {
ServerMessage::EndpointControl { data, .. } => {
@@ -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 972342ac..c2b419f4 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -177,6 +177,8 @@ pub(crate) struct ClientConnection {
pub(crate) shell_location: Option<ClientShellLocation>,
/// Last coherent shell replacement sent to this client.
pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>,
+ /// View policy paired with the last coherent shell replacement.
+ pub(crate) shell_agent_view: Option<crate::api::schema::AgentViewSetParams>,
/// 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 2d652378..beda84b5 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 87c96964..dd2cce38 100644
--- a/src/server/headless/render.rs
+++ b/src/server/headless/render.rs
@@ -479,6 +479,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;
};
@@ -495,19 +496,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");
@@ -519,11 +553,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 bb196be5..0ad5f8a3 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::ClientS
Box::new(serde_json::from_str(&data).expect("decode client shell snapshot"))
}
+fn client_agent_view_projection(
+ message: ServerMessage,
+) -> 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())
}
@@ -737,6 +747,94 @@ fn terminal_client_endpoint_request_error_removes_client() {
}
#[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();
let mut workspace = crate::workspace::Workspace::test_new("shell-only-label");