mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
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:
co-authored by
akbash-bot
JJ Liebig
parent
4e1096b101
commit
3b478e69dc
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
|
||||
];
|
||||
|
||||
mod agent_resume;
|
||||
mod agent_view_eval;
|
||||
mod api;
|
||||
mod app;
|
||||
mod build_info;
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user