fix: scope agent views to the selected machine (#3784)

* fix: scope agent views to the selected machine

refs #3732

* fix: preserve stable agent view ordering

refs #3732

---------

Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
akbash
2026-09-14 02:11:07 +04:00
committed by GitHub
co-authored by akbash-bot JJ Liebig
parent 4e1096b101
commit 3b478e69dc
20 changed files with 1600 additions and 300 deletions
@@ -461,9 +461,13 @@ Built-in filter fields are `status`, `workspace_id`, `tab_id`, `pane_id`,
`agent`, `seen`, and `state_change_seq`. Use `{"token":"name"}` as a field to
filter plugin-reported pane metadata. Values are strings, booleans, unsigned
numbers, or a context object. Context values are `current_workspace_id` and
`current_tab_id`, and may only be compared to the matching ID field. Effective
status values are `idle`, `working`, `blocked`, `done`, and `unknown`; `done`
means idle and not yet seen.
`current_tab_id`, and may only be compared to the matching ID field. In a
saved-machine client, the selected server's view applies to the combined agent
list. Current workspace and tab context includes that selected machine, so an
identical ID on another machine does not match; independent filter branches,
such as `status` being `blocked`, still match agents on any connected machine.
Effective status values are `idle`, `working`, `blocked`, `done`, and `unknown`;
`done` means idle and not yet seen.
Sort fields are `workspace_order`, `tab_order`, `pane_order`, `attention`,
`status`, `agent`, `seen`, `state_change_seq`, or `{"token":"name"}`. Sorts are
+213
View File
@@ -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,
}
}
+117 -185
View File
@@ -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 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 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) -> 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;
+91
View File
@@ -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}"))?;
@@ -43,6 +77,63 @@ mod tests {
));
}
#[test]
fn agent_view_projection_decodes_and_validates_the_view() {
let view = crate::api::schema::AgentViewSetParams {
source: "example.views".into(),
label: Some("focus".into()),
filter: None,
sort: Vec::new(),
};
let crate::protocol::ServerMessage::EndpointControl { kind, data } =
crate::protocol::endpoint::agent_view_projection_message("boot", 4, Some(&view))
.unwrap()
else {
panic!("projection control");
};
let EndpointControlMessage::AgentViewProjection(decoded) =
decode_endpoint_control(&kind, &data).unwrap()
else {
panic!("decoded projection");
};
assert_eq!(decoded.boot_id, "boot");
assert_eq!(decoded.revision, 4);
assert_eq!(decoded.view, Ok(Some(view)));
}
#[test]
fn unsupported_agent_view_payload_falls_back_without_rejecting_endpoint() {
let projection = crate::protocol::endpoint::EndpointAgentViewProjection {
boot_id: "boot".into(),
revision: 5,
view: Some(serde_json::json!({
"source": "example.views",
"filter": {"op": "future_filter"}
})),
};
let decoded = decode_endpoint_control(
crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND,
&serde_json::to_string(&projection).unwrap(),
)
.unwrap();
let EndpointControlMessage::AgentViewProjection(decoded) = decoded else {
panic!("decoded projection");
};
assert_eq!(decoded.view, Err(()));
}
#[test]
fn malformed_agent_view_envelope_is_ignored() {
assert!(matches!(
decode_endpoint_control(
crate::protocol::endpoint::AGENT_VIEW_PROJECTION_KIND,
"not json"
)
.unwrap(),
EndpointControlMessage::Ignored
));
}
#[test]
fn unknown_snapshot_codecs_are_rejected() {
assert_eq!(
+30
View File
@@ -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;
+74 -67
View File
@@ -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,
+194 -17
View File
@@ -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 {
+7 -5
View File
@@ -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
+28 -15
View File
@@ -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()
}
+1
View File
@@ -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() {
+184
View File
@@ -1,5 +1,13 @@
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,
@@ -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,
}
}
+1
View File
@@ -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)
+5 -2
View File
@@ -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
+427
View File
@@ -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();
@@ -659,6 +677,415 @@ fn aggregate_agents_use_configured_rows_machine_token_and_status_colors() {
.any(|cell| cell.symbol() == "×" && cell.fg == state.config.palette.red));
}
#[test]
fn current_workspace_agent_view_excludes_same_workspace_id_on_other_machine() {
use crate::api::schema::AgentStatus;
use crate::config::AgentSidebarToken;
let mut config = Config::default();
config.ui.sidebar.agents.rows =
vec![vec![AgentSidebarToken::Machine, AgentSidebarToken::Agent]];
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
let profile = remote_profile();
let endpoint_id = ClientEndpointId::Ssh(profile.id.clone());
state.set_endpoint_catalog(&[profile]);
state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online);
let mut local = snapshot();
local.agent_view_label = Some("current space".into());
local.agent_order = vec!["pane_1".into()];
local.agents = vec![agent("local agent", AgentStatus::Idle, 1)];
state.set_snapshot(Box::new(local));
state.set_pane_surface(surface());
let mut remote = snapshot();
remote.boot_id = "remote-boot".into();
remote.agent_view_label = Some("current space".into());
remote.agent_order = vec!["pane_1".into()];
remote.agents = vec![agent("remote agent", AgentStatus::Idle, 1)];
state.set_endpoint_snapshot(&endpoint_id, Box::new(remote));
let view = current_workspace_view();
state.set_test_endpoint_agent_view(&ClientEndpointId::Local, Some(view.clone()));
state.set_test_endpoint_agent_view(&endpoint_id, Some(view));
let frame = state.compose(100, 28).expect("combined endpoint frame");
let text = frame
.cells
.chunks(frame.width as usize)
.map(|row| {
row.iter()
.map(|cell| cell.symbol.as_str())
.collect::<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;
+1
View File
@@ -12,6 +12,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
];
mod agent_resume;
mod agent_view_eval;
mod api;
mod app;
mod build_info;
+57
View File
@@ -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,
}
@@ -243,6 +272,33 @@ mod tests {
assert_eq!(decoded, snapshot);
}
#[test]
fn agent_view_projection_is_an_optional_revision_bound_control() {
let view = crate::api::schema::AgentViewSetParams {
source: "example.views".into(),
label: Some("focus".into()),
filter: None,
sort: Vec::new(),
};
let ServerMessage::EndpointControl { kind, data } =
agent_view_projection_message("boot", 7, Some(&view)).unwrap()
else {
panic!("projection should use endpoint control");
};
assert_eq!(kind, AGENT_VIEW_PROJECTION_KIND);
let projection: EndpointAgentViewProjection = serde_json::from_str(&data).unwrap();
assert_eq!(projection.boot_id, "boot");
assert_eq!(projection.revision, 7);
assert_eq!(
projection
.view
.map(serde_json::from_value)
.transpose()
.unwrap(),
Some(view)
);
}
#[test]
fn snapshot_json_tolerates_future_fields_and_command_actions() {
let mut snapshot = match snapshot_message(&snapshot()).unwrap() {
@@ -284,6 +340,7 @@ mod tests {
SURFACE_INTEREST_CAPABILITY.to_string(),
PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(),
HEALTH_CHECK_CAPABILITY.to_string(),
AGENT_VIEW_PROJECTION_CAPABILITY.to_string(),
]
);
}
+3
View File
@@ -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,
+19
View File
@@ -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);
+43 -6
View File
@@ -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 {
+98
View File
@@ -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())
}
@@ -736,6 +746,94 @@ fn terminal_client_endpoint_request_error_removes_client() {
assert!(!server.clients.contains_key(&client_id));
}
#[tokio::test]
async fn client_shell_pairs_agent_view_set_replacement_and_clear_with_snapshots() {
use crate::api::schema::{
AgentViewBuiltinField, AgentViewField, AgentViewFilter, AgentViewSetParams, AgentViewValue,
};
let mut server = test_headless_server();
let (writer, control_rx, _render_rx) = test_client_writer();
assert!(
server.handle_server_event(ServerEvent::ClientShellConnected {
client_id: 77,
surface_cols: 80,
surface_rows: 23,
cell_width_px: 0,
cell_height_px: 0,
pixel_mouse: false,
direct_graphics: false,
endpoint_keybindings: false,
mouse_capture: false,
surface_active: false,
writer,
})
);
let initial = client_shell_snapshot(read_server_message(
control_rx.recv().expect("initial snapshot"),
));
let mut view = AgentViewSetParams {
source: "example.views".into(),
label: Some("focus".into()),
filter: Some(AgentViewFilter::Eq {
field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
value: AgentViewValue::String("working".into()),
}),
sort: Vec::new(),
};
server.app.state.agent_view_override = Some(view.clone());
server.render_and_stream();
let set = client_agent_view_projection(read_server_message(
control_rx.recv().expect("set projection"),
));
let set_snapshot = client_shell_snapshot(read_server_message(
control_rx.recv().expect("set snapshot"),
));
assert!(set.revision > initial.revision);
assert_eq!(set.revision, set_snapshot.revision);
assert_eq!(
set.view.map(serde_json::from_value).transpose().unwrap(),
Some(view.clone())
);
view.filter = Some(AgentViewFilter::Eq {
field: AgentViewField::Builtin(AgentViewBuiltinField::Status),
value: AgentViewValue::String("blocked".into()),
});
server.app.state.agent_view_override = Some(view.clone());
server.render_and_stream();
let replacement = client_agent_view_projection(read_server_message(
control_rx.recv().expect("replacement projection"),
));
let replacement_snapshot = client_shell_snapshot(read_server_message(
control_rx.recv().expect("replacement snapshot"),
));
assert!(replacement.revision > set.revision);
assert_eq!(replacement.revision, replacement_snapshot.revision);
assert_eq!(
replacement
.view
.map(serde_json::from_value)
.transpose()
.unwrap(),
Some(view)
);
server.app.state.agent_view_override = None;
server.render_and_stream();
let cleared = client_agent_view_projection(read_server_message(
control_rx.recv().expect("clear projection"),
));
let cleared_snapshot = client_shell_snapshot(read_server_message(
control_rx.recv().expect("clear snapshot"),
));
assert!(cleared.revision > replacement.revision);
assert_eq!(cleared.revision, cleared_snapshot.revision);
assert!(cleared.view.is_none());
assert!(cleared_snapshot.agent_view_label.is_none());
}
#[tokio::test]
async fn client_shell_receives_metadata_then_shell_free_pane_surface() {
let mut server = test_headless_server();