diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index 684629cdb93..ebaf08ddba6 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -751,6 +751,7 @@ cockpit-task-peek-no-plan = Kein strukturierter Aufgabenplan cockpit-task-peek-state-waiting = Wartet auf Dich cockpit-task-peek-state-working = Arbeitet cockpit-task-peek-state-idle = Inaktiv +cockpit-tree-managed-agent = Verwalteter Agent – Details in der Kontoansicht menu-tab-pin = Tab anheften menu-tab-unpin = Tab lösen theme-editor-open = Theme-Editor öffnen diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 92de6fb102b..f1b63233bf4 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -3028,6 +3028,7 @@ cockpit-task-peek-no-plan = No structured task plan cockpit-task-peek-state-waiting = Waiting on you cockpit-task-peek-state-working = Working cockpit-task-peek-state-idle = Idle +cockpit-tree-managed-agent = Managed agent – details in the account view menu-tab-pin = Pin tab menu-tab-unpin = Unpin tab theme-editor-open = Open theme editor diff --git a/app/src/app_state_tests.rs b/app/src/app_state_tests.rs index 34a283a5ec1..9e356e0b0b3 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -105,6 +105,37 @@ fn test_code_pane_snapshot_with_multiple_tabs() { assert!(matches!(source, Some(CodeSource::Link { .. }))); } +#[test] +fn persisted_cockpit_selection_maps_to_sessions_view() { + // Before the accounts became their own sidebar view (#504) the whole + // Cockpit persisted as "Cockpit". Such a snapshot must keep restoring the + // sessions tree, and the sessions tree must keep that wire name, while + // the new accounts view persists under its own name. + let stored = + r#"{"left_panel_displayed_tab":"Cockpit","pane_group_id":"side-panel","width":287}"#; + let snapshot = serde_json::from_str::(stored) + .expect("a Cockpit snapshot from before #504 must remain readable"); + assert_eq!( + snapshot.left_panel_displayed_tab, + LeftPanelDisplayedTab::Cockpit + ); + assert_eq!( + LeftPanelDisplayedTab::from(ToolPanelView::Cockpit), + LeftPanelDisplayedTab::Cockpit, + "the sessions tree persists under the pre-#504 name" + ); + + let accounts = LeftPanelSnapshot { + left_panel_displayed_tab: ToolPanelView::CockpitAccounts.into(), + pane_group_id: "side-panel".to_string(), + width: 287, + }; + assert_eq!( + serde_json::to_value(&accounts).unwrap()["left_panel_displayed_tab"], + serde_json::json!("CockpitAccounts") + ); +} + #[test] fn test_left_panel_snapshot_preserves_drive_wire_name() { for stored_variant in ["ZapDrive", "ZaplexDrive"] { diff --git a/app/src/cockpit/model.rs b/app/src/cockpit/model.rs index a60e009d921..5f3ef2c7ccb 100644 --- a/app/src/cockpit/model.rs +++ b/app/src/cockpit/model.rs @@ -41,9 +41,9 @@ use zaplex_cockpit::fleet::sort_hosts; use zaplex_cockpit::HostNode; use zaplex_cockpit::{ apply_oauth_usage, apply_session_verdicts, build_snapshot_with_cache, fold_inventory, - mark_registry_bound_hosts_unverified, session_key, AccountOverrides, AgentInventoryStatus, - Attention, CockpitSnapshot, FleetTree, HostAvailability, PricingTable, Provider, - RegisteredHost, RemoteHost, ScanHealth, SeenTurns, SessionSnapshot, SessionVerdict, + mark_registry_bound_hosts_unverified, session_key, AccountOverrides, AccountUsage, + AgentInventoryStatus, Attention, CockpitSnapshot, FleetTree, HostAvailability, PricingTable, + Provider, RegisteredHost, RemoteHost, ScanHealth, SeenTurns, SessionSnapshot, SessionVerdict, TerminalLink, TranscriptScanCache, }; // Cross-host daemon fold is a native-only concern: the `agent_session` module @@ -113,6 +113,67 @@ fn should_apply_refresh_result(current_generation: u64, completed_generation: u6 current_generation == completed_generation } +/// What a remote-manager event means for the Cockpit refresh pipeline. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RemoteRefresh { + /// The set of open connections changed. Scans already in flight describe + /// the old topology, so their results must not be applied (#160: a late + /// inventory must not re-add a disconnected host). + Topology, + /// Session inventory may have changed on an unchanged topology. + Inventory, + /// Neither connections nor session inventory changed. + Ignore, +} + +fn remote_refresh(event: &RemoteServerManagerEvent) -> RemoteRefresh { + match event { + RemoteServerManagerEvent::HostConnected { .. } + | RemoteServerManagerEvent::SessionConnected { .. } + | RemoteServerManagerEvent::SessionDisconnected { .. } + | RemoteServerManagerEvent::SessionReconnected { .. } + | RemoteServerManagerEvent::SessionDeregistered { .. } + | RemoteServerManagerEvent::HostDisconnected { .. } => RemoteRefresh::Topology, + RemoteServerManagerEvent::SessionExited { .. } + | RemoteServerManagerEvent::SessionOpened { .. } + | RemoteServerManagerEvent::SessionInventoryChanged { .. } => RemoteRefresh::Inventory, + RemoteServerManagerEvent::SessionConnecting { .. } + | RemoteServerManagerEvent::SessionConnectionFailed { .. } + | RemoteServerManagerEvent::NavigatedToDirectory { .. } + | RemoteServerManagerEvent::RepoMetadataSnapshot { .. } + | RemoteServerManagerEvent::RepoMetadataUpdated { .. } + | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } + | RemoteServerManagerEvent::BufferUpdated { .. } + | RemoteServerManagerEvent::SetupStateChanged { .. } + | RemoteServerManagerEvent::BinaryCheckComplete { .. } + | RemoteServerManagerEvent::BinaryInstallComplete { .. } + | RemoteServerManagerEvent::ClientRequestFailed { .. } + | RemoteServerManagerEvent::ServerMessageDecodingError { .. } + | RemoteServerManagerEvent::SessionOutput { .. } + | RemoteServerManagerEvent::SessionNotice { .. } + | RemoteServerManagerEvent::ManagedLaunchOpened { .. } + | RemoteServerManagerEvent::ManagedLaunchFailed { .. } => RemoteRefresh::Ignore, + } +} + +/// The local contribution to the live tree: every live account session plus +/// Antigravity's per-workspace resume registry. Antigravity is deliberately +/// Idle: its disk state proves a resumable conversation, not a running +/// process. Claude/Codex dormant history (`AccountUsage::idle_sessions`) +/// remains on its account-detail surfaces and never enters the tree; adding +/// it here would turn provider enablement into a broad Conductor behavior +/// change. +fn local_tree_sessions( + accounts: &[AccountUsage], + antigravity: Vec, +) -> Vec { + accounts + .iter() + .flat_map(|account| account.sessions.iter().cloned()) + .chain(antigravity) + .collect() +} + /// Actor-local single-flight state for full cockpit builds. Ordinary requests /// reserve one follow-up build without invalidating useful work already running. /// Connection changes and disabling invalidate results from the previous topology. @@ -354,39 +415,16 @@ impl CockpitModel { ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, _event, ctx| { me.spawn_refresh(ctx); }); - ctx.subscribe_to_model( - &RemoteServerManager::handle(ctx), - |me, event, ctx| match event { - RemoteServerManagerEvent::HostConnected { .. } - | RemoteServerManagerEvent::SessionConnected { .. } - | RemoteServerManagerEvent::SessionDisconnected { .. } - | RemoteServerManagerEvent::SessionReconnected { .. } - | RemoteServerManagerEvent::SessionDeregistered { .. } - | RemoteServerManagerEvent::HostDisconnected { .. } => { + ctx.subscribe_to_model(&RemoteServerManager::handle(ctx), |me, event, ctx| { + match remote_refresh(event) { + RemoteRefresh::Topology => { me.refresh_flight.invalidate(); me.spawn_refresh(ctx); } - RemoteServerManagerEvent::SessionExited { .. } - | RemoteServerManagerEvent::SessionOpened { .. } - | RemoteServerManagerEvent::SessionInventoryChanged { .. } => me.spawn_refresh(ctx), - RemoteServerManagerEvent::SessionConnecting { .. } - | RemoteServerManagerEvent::SessionConnectionFailed { .. } - | RemoteServerManagerEvent::NavigatedToDirectory { .. } - | RemoteServerManagerEvent::RepoMetadataSnapshot { .. } - | RemoteServerManagerEvent::RepoMetadataUpdated { .. } - | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } - | RemoteServerManagerEvent::BufferUpdated { .. } - | RemoteServerManagerEvent::SetupStateChanged { .. } - | RemoteServerManagerEvent::BinaryCheckComplete { .. } - | RemoteServerManagerEvent::BinaryInstallComplete { .. } - | RemoteServerManagerEvent::ClientRequestFailed { .. } - | RemoteServerManagerEvent::ServerMessageDecodingError { .. } - | RemoteServerManagerEvent::SessionOutput { .. } - | RemoteServerManagerEvent::SessionNotice { .. } => {} - RemoteServerManagerEvent::ManagedLaunchOpened { .. } - | RemoteServerManagerEvent::ManagedLaunchFailed { .. } => {} - }, - ); + RemoteRefresh::Inventory => me.spawn_refresh(ctx), + RemoteRefresh::Ignore => {} + } + }); // Hook status (open prompts) and terminal lifetime change what is // openable and what needs the user without any rescan. @@ -666,25 +704,13 @@ impl CockpitModel { .map(|node_id| (node_id, daemon.host_id.clone())) }) .collect(); - // Local contribution: every live account session plus - // Antigravity's per-workspace resume registry. Antigravity is - // deliberately Idle: its disk state proves a resumable - // conversation, not a running process. Claude/Codex dormant - // histories remain on their existing account-detail surfaces; - // adding all of them here would turn provider enablement into a - // broad Conductor behavior change. let antigravity = zaplex_cockpit::antigravity_idle_sessions( &inputs.home, scan_now, zaplex_cockpit::IDLE_MAX_AGE, zaplex_cockpit::IDLE_SESSION_LIMIT, ); - let local: Vec = snapshot - .accounts - .iter() - .flat_map(|account| account.sessions.iter().cloned()) - .chain(antigravity) - .collect(); + let local = local_tree_sessions(&snapshot.accounts, antigravity); let local_label = inputs.local_label.clone(); let mut inventory = fold_inventory(inputs.local_label, local, Vec::new()); apply_local_scan_health(&mut inventory, &snapshot.health); diff --git a/app/src/cockpit/model_tests.rs b/app/src/cockpit/model_tests.rs index 91573af9c3e..e19b861da80 100644 --- a/app/src/cockpit/model_tests.rs +++ b/app/src/cockpit/model_tests.rs @@ -25,12 +25,6 @@ fn initial_scan_state_is_loading_not_empty() { #[test] fn stale_inventory_cannot_readd_disconnected_host() { - assert!(should_apply_refresh_result(2, 2)); - assert!( - !should_apply_refresh_result(2, 1), - "a scan requested before the current generation must be ignored" - ); - let local = HostNode { host: "local".to_string(), is_local: true, @@ -42,32 +36,110 @@ fn stale_inventory_cannot_readd_disconnected_host() { needs_me: 0, }; let remote = remote_host( - "devhost", - "host-dev", - session("dev-session", zaplex_cockpit::SessionState::Active), + "remote", + "host-remote", + session("remote-session", zaplex_cockpit::SessionState::Active), ); - let stale_result = FleetTree { + // A full scan starts while the remote host is still connected. + let mut flight = RefreshSingleFlight::default(); + assert!(flight.request()); + let in_flight = flight.generation; + let mut visible = FleetTree { hosts: vec![local, remote], needs_me: 0, }; - let mut visible = stale_result.clone(); + // The final session to that host closes before the scan returns. The + // disconnect is a topology change, which the model answers by invalidating + // the running generation and dropping the root synchronously. + let disconnect = RemoteServerManagerEvent::HostDisconnected { + host_id: warp_core::HostId::new("host-remote".to_string()), + }; + assert_eq!(remote_refresh(&disconnect), RemoteRefresh::Topology); + flight.invalidate(); assert!(reconcile_live_daemon_roots( &mut visible, &mut ManagedFleetInventory::default(), "local", &[], )); - let current_generation = 2; - let stale_generation = 1; - if should_apply_refresh_result(current_generation, stale_generation) { - visible = stale_result; - } + // The late scan result belongs to the old topology and is discarded. + assert!( + !should_apply_refresh_result(flight.generation, in_flight), + "a scan started before the disconnect must be ignored" + ); assert_eq!(visible.hosts.len(), 1); assert!(visible.hosts[0].is_local); } +#[test] +fn connection_changes_invalidate_inflight_scans_but_inventory_changes_do_not() { + let host_id = || warp_core::HostId::new("host-remote".to_string()); + assert_eq!( + remote_refresh(&RemoteServerManagerEvent::HostConnected { host_id: host_id() }), + RemoteRefresh::Topology, + "a newly connected host must not be hidden by an older scan" + ); + assert_eq!( + remote_refresh(&RemoteServerManagerEvent::HostDisconnected { host_id: host_id() }), + RemoteRefresh::Topology, + ); + assert_eq!( + remote_refresh(&RemoteServerManagerEvent::SessionInventoryChanged { host_id: host_id() }), + RemoteRefresh::Inventory, + "a managed Stop/Restart refreshes without discarding the running scan" + ); +} + +#[test] +fn dormant_account_history_never_enters_local_tree() { + let live = raw_row("live", zaplex_cockpit::SessionState::Active); + let dormant = raw_row("dormant", zaplex_cockpit::SessionState::Idle); + let account = zaplex_cockpit::AccountUsage { + account: zaplex_cockpit::Account { + provider: Provider::Claude, + key: "claude".to_string(), + config_dir: PathBuf::from("/accounts/claude"), + label: "Claude".to_string(), + provider_account_id: None, + email: None, + org: None, + role: None, + plan_tier: None, + is_default: true, + }, + block5h: zaplex_cockpit::WindowTotals::default(), + today: zaplex_cockpit::WindowTotals::default(), + today_by_session: Default::default(), + week: zaplex_cockpit::WindowTotals::default(), + reset5h: None, + reset_week: None, + heat: 0.0, + heat_week: 0.0, + heat_opus: None, + heat_sonnet: None, + sessions: vec![live], + idle_sessions: vec![dormant], + status: zaplex_cockpit::AccountStatus::Live, + provenance: zaplex_cockpit::UsageProvenance::Estimate, + }; + let resumable = raw_row("antigravity-resume", zaplex_cockpit::SessionState::Idle); + + let local = local_tree_sessions(std::slice::from_ref(&account), vec![resumable]); + + let ids: Vec<&str> = local + .iter() + .map(|session| session.session_id.as_str()) + .collect(); + assert_eq!( + ids, + vec!["live", "antigravity-resume"], + "dormant Claude/Codex history stays in the account detail, not the live tree" + ); + assert_eq!(account.idle_sessions.len(), 1, "the history itself is kept"); +} + #[test] fn blocked_build_coalesces_refresh_triggers_into_one_rerun() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/app/src/cockpit/pane.rs b/app/src/cockpit/pane.rs index 310853bdaef..80dd03eff8d 100644 --- a/app/src/cockpit/pane.rs +++ b/app/src/cockpit/pane.rs @@ -120,6 +120,10 @@ const ALIAS_EDITOR_WIDTH: f32 = 220.0; const CARD_PADDING: f32 = 12.0; const CARD_SPACING: f32 = 8.0; const HEAT_BAR_HEIGHT: f32 = 8.0; +/// Width of a meter's short window label and the gap before its track; a +/// reset line under the meter starts at the track's edge. +const METER_LABEL_WIDTH: f32 = 24.0; +const METER_LABEL_GAP: f32 = 8.0; /// Fixed column width for the cost/token matrix cells. const MATRIX_COL_WIDTH: f32 = 110.0; @@ -1217,11 +1221,11 @@ impl CockpitPaneView { self.account_key.as_deref() } - /// The ONE reset-countdown line, shared by the fleet card and the account - /// detail: `5h ↻ · Wo ↻ ` — absent windows drop out, `None` when - /// neither is known. Labels are the same short meter vocabulary as - /// [`Self::heat_bar`]'s, so the line reads against the meters above it - /// (audit P0.4: bare times with no label read as debug output). + /// The fleet card's reset-countdown line: `5h ↻ · Wo ↻ ` — absent + /// windows drop out, `None` when neither is known. Labels are the same + /// short meter vocabulary as [`Self::heat_bar`]'s, so the line reads + /// against the meters above it (audit P0.4: bare times with no label read + /// as debug output). fn reset_line(acct: &AccountUsage, now: chrono::DateTime) -> Option { let label_5h = crate::t!("cockpit-meter-5h"); let label_week = crate::t!("cockpit-meter-week"); @@ -1328,10 +1332,10 @@ impl CockpitPaneView { Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(8.0) + .with_spacing(METER_LABEL_GAP) .with_child( ConstrainedBox::new(Self::text(label.to_string(), family, size, muted)) - .with_width(24.0) + .with_width(METER_LABEL_WIDTH) .finish(), ) .with_child(track) @@ -2986,24 +2990,40 @@ impl CockpitPaneView { // The two meters. `heat_bar` carries the one theme-role utilisation rule. // Meter labels are the short vocabulary („5h"/„Wo"), same as the fleet // card — the long column titles clipped inside the label cell (audit - // P0.3); they belong to the figures matrix below. - col = col.with_child(self.heat_bar( - &crate::t!("cockpit-meter-5h"), - acct.heat, - acct.provenance, - appearance, - )); - col = col.with_child(self.heat_bar( - &crate::t!("cockpit-meter-week"), - acct.heat_week, - acct.provenance, - appearance, - )); - // ONE reset line under both meters, in the fleet card's format - // (`5h ↻ … · Wo ↻ …`) — bare times with no label read as debug output - // (audit P0.4). - if let Some(reset_line) = Self::reset_line(acct, now) { - col = col.with_child(Self::text(reset_line, family, body, faint)); + // P0.3); they belong to the figures matrix below. Each limit keeps its + // own reset directly under its meter, starting at the track's edge, so + // the window label above names it (#160 UI addendum). + let meters = [ + (crate::t!("cockpit-meter-5h"), acct.heat, acct.reset5h), + ( + crate::t!("cockpit-meter-week"), + acct.heat_week, + acct.reset_week, + ), + ]; + for (label, fraction, reset) in meters { + let mut quota = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(2.0) + .with_child(self.heat_bar(&label, fraction, acct.provenance, appearance)); + let reset = format_reset(reset, now); + if !reset.is_empty() { + quota = quota.with_child( + Container::new(Self::text(format!("↻ {reset}"), family, body, faint)) + .with_padding_left(METER_LABEL_WIDTH + METER_LABEL_GAP) + .finish(), + ); + } + col = col.with_child(quota.finish()); + } + // 7-day per-model sublimits (Max plans) when the usage endpoint reports + // them — often the binding limit, so the account detail lists them like + // the fleet card does. + for (label, sublimit) in [("opus", acct.heat_opus), ("sonnet", acct.heat_sonnet)] { + if let Some(fraction) = sublimit { + col = col.with_child(self.heat_bar(label, fraction, acct.provenance, appearance)); + } } // Three windows × ($ / tokens). "Today" is the LOCAL day (F2). diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index c602230fe8b..28fb1eb9ee8 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -238,6 +238,8 @@ pub struct CockpitPanel { /// Tooltip hover state for each agent status glyph. Kept separate from the /// clickable row handle so only the glyph owns this tooltip. conductor_row_glyph_states: HashMap, + /// Tooltip hover state for the managed-fleet marker of an agent row. + conductor_managed_marker_states: HashMap, /// Hover/click state per connected host root, keyed by stable host identity. conductor_host_states: HashMap, /// Tooltip hover state for each host summary glyph. @@ -818,6 +820,7 @@ impl CockpitPanel { session_scroll_state: ClippedScrollStateHandle::default(), conductor_row_states: HashMap::new(), conductor_row_glyph_states: HashMap::new(), + conductor_managed_marker_states: HashMap::new(), conductor_host_states: HashMap::new(), conductor_host_glyph_states: HashMap::new(), expanded_hosts: HashMap::new(), @@ -894,6 +897,8 @@ impl CockpitPanel { .retain(|k, _| routable.contains(k)); self.conductor_row_glyph_states .retain(|k, _| visible.contains(k)); + self.conductor_managed_marker_states + .retain(|k, _| visible.contains(k)); self.conductor_title_states.retain(|k, _| { visible.contains(k) || session_keys.contains(k) @@ -905,6 +910,9 @@ impl CockpitPanel { } for key in visible { self.conductor_title_states.entry(key.clone()).or_default(); + self.conductor_managed_marker_states + .entry(key.clone()) + .or_default(); self.conductor_row_glyph_states.entry(key).or_default(); } // Connected host handles and explicit expansion overrides. @@ -1229,10 +1237,11 @@ impl CockpitPanel { /// Provider icon, provider and model of one agent, separated by air, not /// by a glyph. As a row's headline (`headline = Some(color)`) the provider /// takes the title tone; as the second line under a session title the - /// provider is muted and the model quieter still. + /// provider is muted and the model quieter still. A managed-fleet agent + /// (#168) ends with the `◆` marker, explained by its tooltip. fn agent_identity_line( agent: &SessionSnapshot, - is_managed: bool, + managed_marker: Option, headline: Option, appearance: &Appearance, ) -> Box { @@ -1278,10 +1287,18 @@ impl CockpitPanel { ), None => line, }; - let line = if is_managed { - line.with_child(Self::text("◆".to_string(), family, footnote, muted)) - } else { - line + let line = match managed_marker { + Some(tooltip_state) => { + line.with_child(appearance.ui_builder().overlay_tool_tip_on_element( + crate::t!("cockpit-tree-managed-agent"), + tooltip_state, + Self::text("◆".to_string(), family, footnote, muted), + ParentAnchor::TopMiddle, + ChildAnchor::BottomMiddle, + vec2f(0.0, -4.0), + )) + } + None => line, }; line.finish() } @@ -1309,9 +1326,15 @@ impl CockpitPanel { TitleTone::Active => theme.main_text_color(theme.surface_1()).into_solid(), TitleTone::Quiet => theme.sub_text_color(theme.surface_1()).into_solid(), }; - let is_managed = managed_fleet + let managed_marker = managed_fleet .matching_agent_session(host_id, agent) - .is_some(); + .is_some() + .then(|| { + self.conductor_managed_marker_states + .get(&key) + .cloned() + .unwrap_or_default() + }); let text = match label { Some(label) => Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) @@ -1319,10 +1342,13 @@ impl CockpitPanel { .with_spacing(1.0) .with_child(self.tree_label(label, &key, body, tone_color, appearance)) .with_child(Self::agent_identity_line( - agent, is_managed, None, appearance, + agent, + managed_marker, + None, + appearance, )) .finish(), - None => Self::agent_identity_line(agent, is_managed, Some(tone_color), appearance), + None => Self::agent_identity_line(agent, managed_marker, Some(tone_color), appearance), }; let line = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 99e88714c07..7f8ef29c43e 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -5100,7 +5100,6 @@ impl Input { ctx.notify(); } - #[cfg(test)] pub(crate) fn ordinary_command_input_is_ready(&self) -> bool { self.ordinary_command_input_ready } diff --git a/crates/zaplex_cockpit/src/conductor_tests.rs b/crates/zaplex_cockpit/src/conductor_tests.rs index 4fd6a135295..34e3cc8f079 100644 --- a/crates/zaplex_cockpit/src/conductor_tests.rs +++ b/crates/zaplex_cockpit/src/conductor_tests.rs @@ -456,15 +456,41 @@ fn tree_hierarchy_is_host_project_pty_agent() { let mut replacement = session("replacement", "/p/project", SessionState::Idle, 30); replacement.pty_session_id = Some("pty-1".to_string()); replacement.pty_session_generation = Some(5); + let other_project = session("other", "/p/other", SessionState::Idle, 40); + let elsewhere = session("elsewhere", "/p/project", SessionState::Active, 50); - let agents = [claude, codex, replacement]; - let sessions = group_project_sessions(true, None, &agents); + let tree = build_fleet_tree(vec![ + host("alpha", vec![claude, codex, replacement, other_project]), + remote_host("beta", "beta-id", vec![elsewhere]), + ]); + + // Host: every host is its own root, even for an identical repository path. + assert_eq!(tree.hosts.len(), 2); + let alpha = tree.hosts.iter().find(|host| host.host == "alpha").unwrap(); + let beta = tree.hosts.iter().find(|host| host.host == "beta").unwrap(); + assert_eq!(beta.projects.len(), 1); + assert_eq!(beta.projects[0].sessions[0].session_id, "elsewhere"); + + // Project: one node per repository root of that host. + assert_eq!(alpha.projects.len(), 2); + let project = alpha + .projects + .iter() + .find(|project| project.root == "/p/project") + .unwrap(); + assert_eq!(project.sessions.len(), 3); + // PTY session → Agent: agents of one PTY generation share one session; + // a new generation of the same PTY is a new session. + let sessions = + group_project_sessions(alpha.is_local, alpha.host_id.as_deref(), &project.sessions); assert_eq!(sessions.len(), 2, "a new PTY generation is a new session"); assert_eq!(sessions[0].agents.len(), 2); assert_eq!(sessions[0].state, SessionState::Waiting); assert_eq!(sessions[0].needs_me, 1); assert_eq!(sessions[0].agents[0].session_id, "codex"); + assert_eq!(sessions[1].agents.len(), 1); + assert_eq!(sessions[1].agents[0].session_id, "replacement"); } #[test] diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index c7af73bba4c..b6406df35c8 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -46,7 +46,7 @@

UI-Spezifikation — Cockpit, Verbindungen und Mehrhost-Panes (#160, #459–

Flächen und Quellen

  • #spec C1 Verbindungen ist das eigenständige Register für alle konfigurierten Hosts und deren Favoriten.
  • -
  • #spec C2 Das Tab-Menü projiziert ausschließlich favorisierte Hosts; der Hostname verbindet einmal direkt in einem neuen Tab, ein separates stabiles ⋯ öffnet ein echtes seitliches Flyout mit sichtbar bleibendem Parent. Es speichert keine zweite Konfiguration.
  • +
  • #spec C2 Die erste Ebene des Tab-Menüs projiziert unter „Favoriten“ ausschließlich favorisierte Hosts; alle übrigen registrierten Hosts stehen nur im Untermenü „Weitere Hosts“ (pane-launch-and-tab-join.html, L2/L3). Der Hostname verbindet einmal direkt in einem neuen Tab, ein separates stabiles ⋯ öffnet ein echtes seitliches Flyout mit sichtbar bleibendem Parent. Es speichert keine zweite Konfiguration.
  • #spec C3 Es entsteht keine neue vertikale Symbolleiste. Die vorhandene horizontale Sidebar-Navigation bleibt das einzige Navigationsmittel; „KI-Sessions“, „KI-Konten“ und „Verbindungen“ sind darin eigene Einträge (#504).
  • #spec C4 Resiliente Hosts haben dauerhaft sichtbare Aufklapp- und Refresh-Aktionen in festen Slots. Refresh öffnet bei Bedarf den Recovery-Bereich. Darunter liegt die „Zaplex-Sessions“-Recovery; Shellnamen sind keine Sessionidentität. Puffer-/RAM-Diagnostik gehört nur in explizite Verbindungsdetails und wird nach der tatsächlichen Messgröße benannt.
  • #spec C5 Recovery-Abschnitte beginnen auf der Textachse des Hosts. Zaplex- und tmux/byobu-Sessions stehen eine Einrückung (16 px) darunter; ihre Metadaten beginnen auf derselben Achse wie der Sessiontitel.
  • @@ -61,6 +61,7 @@

    Live-Baum

  • #spec T5 Darstellung „B+“ (#505): Ein Projekt mit genau einer PTY-Session ist eine Zeile; der Projektname führt, der eigene Titel der Session folgt gedimmt mit Abstand und weicht zuerst. Bei mehreren Sessions ist eine PTY-Session mit einem Agenten eine Zeile mit Titel und darunter Provider-Icon, Provider und Modell; ohne eigenen Titel ist der Agent die einzeilige Überschrift. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen, ohne Sammelsymbol. Referenz: cockpit-sessions-tree.html.
  • #spec T6 Ein langer Präfix, den ein Titel mit mindestens einem gleichrangigen Titel teilt, wird gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Ein langer Titel behält sein kurzes letztes Segment und kürzt in der Mitte. Kann der sichtbare Titel kürzer sein, steht der volle im Tooltip. Titelteile, Provider und Modell trennen Abstand und Tonwert, keine Trennzeichen wie Mittelpunkte.
  • #spec T7 Feste Chevron- und Symbolspalten: Titel einer Ebene teilen eine Textachse. Ruhende Sessions nutzen die gedämpfte Textrolle; die Zeile des Agenten der fokussierten Pane trägt eine stabile Akzent-Hinterlegung, eine zugeklappte Zeile, die ihn verbirgt, an seiner Stelle. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • +
  • #spec T8 Ein verwalteter Agent (#168) trägt hinter Provider und Modell die Marke ◆. Ein lokalisierter Tooltip erklärt sie („Verwalteter Agent – Details in der Kontoansicht“); weitere Angaben stehen nur in der Kontoansicht.
  • #spec T4 Der Baum zeigt nur Agent-Sessions, die Zaplex öffnen kann – nach derselben Erreichbarkeitsregel wie der Klick: eine Zaplex-Pane hostet die Session, eine Remote-Live-Session hat ein reattachbares Daemon-PTY mit Registry-Route, oder die Session ruht und wird fortgesetzt. Außerhalb von Zaplex gestartete Agents, die Zaplex so nicht öffnen kann, erscheinen nirgends: nicht im Baum, nicht im Zähler, nicht im Dock-Badge, nicht in der Inbox, nicht in Palette, Konto-Tabellen oder Dashboard; nur die Verbrauchszahlen des Kontos enthalten sie weiter. Zaplex-Zugehörigkeit hängt nicht an der optionalen Hook-Bridge: Ein lokaler Agent-Prozess, der die ZAPLEX_SURFACE_ID einer Zaplex-Pane geerbt hat, gehört zu dieser Pane, die auch sein Klickziel ist. Von Zaplex gestartete oder gehostete Sessions (Daemon-PTY-Bindung, exakte Launch-Bindung, Zaplex-Terminal-Hook, geerbte Pane-ID) bleiben auch während eines Reconnects sichtbar, zählen aber erst wieder, wenn ein Klick sie öffnen kann. Lässt sich der Prozess eines lokalen Agents nicht prüfen, wird er nie ausgeblendet.
@@ -71,6 +72,7 @@

Status und Identität

  • #spec S2 Nur „wartet auf Dich“ pulsiert und ist amber: 1,6 s, Ring maximal etwa 2× Punktdurchmesser; Reduced Motion zeigt einen statischen Halo. „Wartet auf Dich“ heißt genau: (a) eine offene Frage oder Freigabe/Permission-Prompt blockiert den Agent – unabhängig von Alter und Ansehen –, oder (b) der Agent hat eine Runde beendet, die Du noch nicht gesehen hast. Eine beendete Runde ist gesehen, sobald Du ihre Session öffnest, fokussierst oder ihre Pane im aktiven Fenster ansiehst; erst die nächste beendete Runde zählt wieder. Eine ruhende, gesehene Session zeigt den neutralen Ring, egal wie lange sie ruht.
  • #spec S3 Titelleisten-Puls, Dock-Badge, Kopfzähler der Session-Sektion und Inbox zeigen dieselbe Zahl: die Sessions aus S2, die Zaplex jetzt öffnen kann. Ein Klick auf den Puls öffnet die nächste gezählte Session; jedes gezählte Ziel ist öffenbar. Die Gesehen-Markierung hängt an Session-Identität plus Runden-Identität, nicht an der Uhrzeit; Runden, die vor dem App-Start endeten, zählen nach dem Start nicht. Wartet nichts, meldet ein lokalisierter Hinweis das ruhig.
  • #spec I1 Provider ist die Überschrift, das Konto die Unterzeile; identische Konto-/E-Mail-Werte werden nie doppelt gerendert.
  • +
  • #spec I2 Die große Konto-Pane stapelt den 5-h- und den Wochenbalken; jede Reset-Angabe steht direkt unter ihrem Balken, bündig mit dessen Spur. Meldet der Anbieter Opus-/Sonnet-Teillimits, folgen sie als weitere Balken. Ist ein Wert geschätzt, erklärt eine Legende unter der Session-Tabelle die Markierung ~ und den Stichtag der Listenpreise.
  • diff --git a/script/check-compact-row-actions b/script/check-compact-row-actions index 8c795d621f8..9d2a39a8160 100755 --- a/script/check-compact-row-actions +++ b/script/check-compact-row-actions @@ -64,7 +64,8 @@ if ! rg --quiet 'ActionButton::new\("", PaneHeaderTheme\)' app/src/ui_components fail=1 fi -if ! rg --quiet --multiline --pcre2 'Shrinkable::new\(\s*1\.0,\s*Self::identity_text\(\s*label,' app/src/cockpit/panel.rs; then +# The full-title tooltip may wrap the identity; the flexible slot must still own it. +if ! rg --quiet --multiline --pcre2 'Shrinkable::new\(\s*1\.0,\s*(?:appearance\.ui_builder\(\)\.overlay_tool_tip_on_element\((?:[^;()]|\([^()]*\))*?)?Self::identity_text\(\s*label,' app/src/cockpit/panel.rs; then echo "cockpit host identity must own the flexible row width" >&2 fail=1 fi diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index cc14cd0b524..846b7d1b881 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -63,7 +63,8 @@ The previous static illustrations are superseded, not alternative approved layou total host RAM. 3. **Favorites project into the tab `+` menu.** The menu reads stable host references from the - Connections registry and shows favorite hosts only. Clicking the host label connects exactly + Connections registry. Its first level shows favorite hosts only; every other registered host + appears only in the "More hosts" submenu (#32). Clicking the host label connects exactly once in a new tab. A separate stable `⋯` action opens a real side flyout while the parent menu stays visible; the flyout contains New Agent, Edit Connection, and Remove from Favorites. It never expands actions between rows, and clicking `⋯` never starts a connection. Editing a @@ -84,11 +85,13 @@ The previous static illustrations are superseded, not alternative approved layou sessions, a session with one agent is one row: its title, then a second line with provider icon, provider, and model; an untitled session there shows its agent as the one-line headline. Only a PTY session with several agents keeps agent rows, and its row carries no aggregate - glyph. A title sharing a long, separator-bounded prefix with at least one sibling shows it - shortened and dimmed while at least eight distinguishing characters stay visible. A long title - keeps a short last segment and shortens in the middle (`feat/checkout-re…-step-2`); otherwise - it ends in an ellipsis. Whenever the visible title can be shorter, the full title is the - tooltip. Title parts, provider, and model are separated by air and tone, never by separator + glyph. A managed-fleet agent (#168) ends its provider/model line with a `◆` marker whose + localized tooltip explains it; details stay in the account pane. A title sharing a long, + separator-bounded prefix with at least one sibling shows it shortened and dimmed while at + least eight distinguishing characters stay visible. A long title keeps a short last segment + and shortens in the middle (`feat/checkout-re…-step-2`); otherwise it ends in an ellipsis. + Whenever the visible title can be shorter, the full title is the tooltip. Title parts, + provider, and model are separated by air and tone, never by separator glyphs such as a middle dot. Every row reserves the chevron and glyph columns, so titles of one depth share a text axis. Idle session titles use the muted text role, and a merged row takes the tone of its session. The row of the focused pane's agent keeps a stable accent tint; a @@ -181,8 +184,10 @@ The previous static illustrations are superseded, not alternative approved layou provider strip does not repeat the same heading above the card. 16. **Account cards retain both subscription windows.** Each sidebar account card shows the - five-hour and weekly usage meters. The large pane may add reset times, tokens, and cost - provenance without duplicating the identity heading. + five-hour and weekly usage meters. The large pane stacks the same two meters and shows each + window's reset time directly under its own meter, followed by the weekly Opus/Sonnet + sublimits when the provider reports them; it may add tokens and cost provenance without + duplicating the identity heading. 17. **Sessions needing the user are emphasized across the detail row.** In large Claude and Codex session tables, a session waiting on you (#12) receives a subtle amber-tinted background across the full, diff --git a/specs/GH-160/REFERENCE_AUDIT.md b/specs/GH-160/REFERENCE_AUDIT.md index 899b60be16b..727776c0eec 100644 --- a/specs/GH-160/REFERENCE_AUDIT.md +++ b/specs/GH-160/REFERENCE_AUDIT.md @@ -287,6 +287,48 @@ Cargo-, Mutations- und Zwei-Host-Ergebnisse liefern. Der frühere grüne statisc `088efe3f1` ist kein Nachweis für die danach hinzugekommenen Änderungen. GH-160/GH-169 bleiben bis zur vollständigen verlinkten Abnahme offen. +## Stage 9 — Abgleich nach Sidebar-Trennung und Baumdarstellung B+ + +Referenzen synchronisiert: **2026-10-09T16:21:43Z**. Beide Referenz-Repositories wurden frisch +gefetcht und ausschließlich per Fast-forward aktualisiert; beide waren bereits aktuell. Ihre +sauberen `main`-Trees entsprechen `origin/main`, und `git ls-remote` bestätigt dieselben +Default-Branch-SHAs. Beide sind gegenüber Stage 8 unverändert. + +| Repository / Baum | Branch | Stand | +|---|---|---| +| Zaplex | `origin/main` | `0ba54c049eff50a904a3b67842abe6b85fa06d10` | +| Zaplex Arbeitsstand dieses Audits | `fix/160-cockpit-restluecken`, Basis `0ba54c049` | plus Änderungen dieser Stage | +| `claudeplex` | `origin/main` | `8c2041ff68d97463aed7aeb01da0f16b708b8e22` | +| `claudeplex-desktop` | `origin/main` | `8c0aad0a944a8f5b6a26636d0827db57ca22d0f3` | + +Da die Referenzen stillstehen, vergleicht diese Stage die Zaplex-Änderungen seit Stage 8 +(`8375611` bis `0ba54c049`, vor allem #504/#505 über PR #529 und die Aufmerksamkeitslogik aus +`88a67df69`) erneut mit dem Referenzcode. + +| Bereich | Referenzverhalten | Zaplex | Einordnung | Nachweis | +|---|---|---|---|---| +| Konten und gepinnte Wurzeln | TUI vereinigt Standard-, Nachbar-, Prozess- und Environment-Wurzeln (`src/discover.ts`). | Seit Stage 8 unverändert; nur die Testisolation von Prozesswurzeln wurde ergänzt. | Parität für Claude, Erweiterung für Codex. | `claude_tests.rs`, `codex_tests.rs`; Matrixszenarien mit `pinned` und `multiple`. | +| Claude-/Codex-Sessions | Registry, lebender Prozess und Transkript werden verbunden (TUI `src/collect.ts:560-599`). | Codex-Rollouts liefern zusätzlich die Turn-ID der letzten Runde (`88a67df69`). | Parität, für Codex Erweiterung. | `codex_sessions_tests.rs`, `sessions_tests.rs`. | +| Live, ruhend, fortsetzbar | Ruhende Historie wird unabhängig von der Registry gesucht (TUI `src/collect.ts:685-718`). | Unverändert. Die lokale Baumquelle ist jetzt die reine Funktion `local_tree_sessions`; ruhende Claude-/Codex-Historie bleibt in der Kontoansicht. | Parität. | `dormant_history_never_enters_live_tree`, neu `dormant_account_history_never_enters_local_tree`; Fortsetzen über `only_a_dormant_session_can_be_resumed`. | +| Status und Aufmerksamkeit | TUI markiert Zustandswechsel erst nach der ersten Beobachtung (`src/tracker.ts:32-72`); Desktop sortiert Wartende nach oben. | Gezählt werden offene Rückfragen und ungesehene beendete Runden; „gesehen“ hängt an Session plus Turn-ID, Runden vor App-Start zählen nicht. Außerhalb von Zaplex gestartete, nicht öffenbare Agents erscheinen nicht in der UI, ihre Kosten zählen weiter. | Bewusste Abweichung nach PRODUCT §4/§12/§13 und `#spec S2`, `S3`, `T4`. | `model_tests.rs` (Attention-Projektion), `capabilities_tests.rs`. | +| Verbrauch und Kostenherkunft | Desktop liest das OAuth-Token aus `.credentials.json`, auf macOS nur für das Standardkonto aus dem Schlüsselbund, sonst Schätzung mit Markierung (`electron/usage.ts:38-58`). | Identische Tokenquelle (`app/src/cockpit/oauth.rs`), Schätzungen tragen `~`, die Legende nennt den Stichtag der Listenpreise. | Parität. Die Beobachtung aus #459, dass auf einem Mac alle Konten geschätzt waren, lässt sich hier nicht nachstellen; gepinnte Konten ohne eigene Credentials-Datei fallen in beiden Produkten auf die Schätzung zurück. | `oauth_tests.rs`; `cockpit-pane-provenance-legend`. | +| Kontoidentität und Kontoansicht | Desktop `AccountDetail` zeigt 5-h-, Wochen-, Opus- und Sonnet-Balken sowie den 5-h-Reset (`src/views/AccountDetail.tsx:40-64`). | Provider als Überschrift, Konto und Plan darunter; jede Reset-Angabe steht unter ihrem Balken. **Regression behoben:** Die Opus-/Sonnet-Teillimits fehlten in der Pane eines einzelnen Kontos und standen nur in der Flottenkarte. | Fix in dieser Stage, Reset je Balken geht über die Referenz hinaus. | `pane.rs::render_account_detail`; `#spec I1`, `I2`. | +| Sidebar-Aufteilung | Desktop wechselt pro Aktivität zwischen `AccountsSidebar` und `AgentsSidebar` (`src/shell/Shell.tsx:20-31`). | Seit #504 zwei eigene Einträge „KI-Sessions“ und „KI-Konten“ im vorhandenen Toolbelt; die 3/5-Höhe ist entfallen. | Parität wiederhergestellt, ohne neue Navigationsleiste. | `toolbelt_has_separate_sessions_and_accounts_views`, `waiting_signal_visible_on_sessions_entry_while_accounts_active`, neu `persisted_cockpit_selection_maps_to_sessions_view`. | +| Baumdarstellung | TUI zeigt Host, Ordner und Session ohne PTY-Ebene (`src/index.ts:432-519`). | #505 Darstellung B+ auf dem unveränderten Modell `Host → Projekt → PTY-Session → Agent`. Die Marke `◆` verwalteter Agents hatte keinen Tooltip; jetzt lokalisiert erklärt. | Bewusste Erweiterung; Tooltip-Lücke behoben. | `project_tree_rows`-Tests in `panel_tests.rs`, erweitert `tree_hierarchy_is_host_project_pty_agent`; `#spec T5`–`T8`. | +| Favoriten, Verbindungen, Start | Keine Host-Favoriten; TUI startet mit gepinntem Konto und Ordner, Desktop wählt das freieste Konto. | Gemeinsames Startmenü für Tab und Pane mit „Weitere Hosts“ (#522), Hostname verbindet direkt, `⋯` öffnet das Flyout (#463). | Zaplex-spezifisch, keine Regression. | `connections_registry_drives_favorite_launch_menu`; `pane-launch-and-tab-join.html`. | + +Weitere Befunde dieser Stage betreffen die Prüfkette, nicht die Referenzparität: Der letzte +ci-batch auf `b813a4759` scheiterte am statischen Guard `check-compact-row-actions` (das Muster +erkannte den in #529 ergänzten Tooltip um den Hosttitel nicht) und auf macOS an einem nur unter +`cfg(test)` definierten Aufruf aus #521. Beides ist im Arbeitsstand korrigiert. Statische Prüfungen +dieser Stage: `cockpit-parity-audit validate` (107 benannte Tests, neun Szenarien) und `self-test` +(28 Fälle) bestanden; der Guard wurde ohne ripgrep nachgebildet und besteht. Kompilierung, Clippy, +Rust-Tests, Mutationen und Screenshots sind nicht gelaufen; sie stehen im nächsten gebündelten +ci-batch aus. + +Offene Punkte in eigenen Issues: Remote-Inventur an der 10-s-Frist (#507), Verbindungen-Grammatik +und Sessionzeilen (#455, #509), Pane- und Dateimanager-Titel (#461), Transferziele (#464). + ## Repeat procedure 1. Fetch all three remotes and record default branch, exact revision, and audit time. diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index eb5b06de60d..f64437c499e 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -46,7 +46,9 @@ runtime diagnostics remain available in explicit connection details and use thei name; an output/replay buffer must not be presented as total host RAM. The tab `+` menu in `app/src/workspace/view.rs` continues to query `FavoritesStore` and resolve its -stable references against the SSH registry. The host-label hit target directly dispatches the +stable references against the SSH registry. Its first level lists favorite hosts only; the +remaining registered hosts form the "More hosts" submenu of the shared launch menu +(`docs/ui/pane-launch-and-tab-join.html`). The host-label hit target directly dispatches the existing new-tab connection action exactly once. A separate stable `⋯` target opens a side flyout for New Agent, Edit Connection, and Remove from Favorites; the parent remains visible and no child rows are inserted into its layout. The flyout reuses the existing menu/overlay, focus-return, @@ -278,7 +280,8 @@ depend on pixels: - a collapsed count turns amber if it hides waiting attention; - session containers have hierarchy only, not a duplicated aggregate state glyph; - agent leaves render state glyph, provider, and optional model only; managed-fleet agents (#168) - add the compact `◆` marker, whose details stay in the account pane; + add the compact `◆` marker with the localized tooltip `cockpit-tree-managed-agent` (its own + `MouseStateHandle` per agent key, like the state glyph); details stay in the account pane; - the B+ display rule (#505) is the pure projection `project_tree_rows`: a project with exactly one PTY session becomes one row (`merged_label`: project name with `PartFit::Holds`, the session title dimmed after `MERGED_TITLE_GAP`); a single-agent PTY session among several is one @@ -459,7 +462,10 @@ Introduce or reuse one pure identity presentation helper in the Cockpit view lay The sidebar cards retain both five-hour and weekly meters. The large pane may retain detailed reset, token, price, and provenance facts, but removes a separate provider strip that duplicates -the card headline. +the card headline. In `render_account_detail` each meter carries its own reset countdown directly +beneath it, indented to the track edge (`METER_LABEL_WIDTH + METER_LABEL_GAP`), followed by the +reported Opus/Sonnet sublimits (`heat_opus`, `heat_sonnet`) as in the fleet dashboard card, which +keeps its single combined reset line (`reset_line`). In the large session table, waiting rows receive a subtle whole-row amber background using existing semantic theme colors with low opacity. Row geometry and columns remain unchanged, and the Status @@ -556,10 +562,10 @@ tree and three-level hierarchy as superseded by GH-160. |---|---| | 1–3 Connections/favorites/menu | SSH row projection tests; stable-favorite identity; direct-connect vs `⋯` propagation; parent/flyout mouse, keyboard, focus-return, safe-triangle, and edge-placement tests | | 4, 8–10, 14 tree identity and grouping | `conductor_tests.rs` (jump targets only counted rows), presentation-descriptor tests, exact route assertions | -| 5–7 host lifecycle/inventory | `fleet_tests.rs` and `model_tests.rs` for local empty, first/last connection, unsupported/unavailable, stale generation | +| 5–7 host lifecycle/inventory | `fleet_tests.rs` and `model_tests.rs` for local empty, first/last connection, unsupported/unavailable, stale generation, and which manager events invalidate in-flight scans (`remote_refresh`) | | 11–13 glyphs/pulse | pure state/pulse geometry tests plus static source/UI-spec checks, including reduced motion; attention verdicts and the seen ledger in `fleet_tests.rs`, the projection (external exclusion, reconnect, viewing, startup baseline) in `model_tests.rs`, reachability in `capabilities_tests.rs`, wire fields in `agent_session_tests.rs` | | 15–17 identity/meters/waiting row | pure identity and row-style tests plus HTML visual states | -| 18–22 discovery/history | Claude/Codex root fixtures and `sessions_tests.rs` legacy/current/dormant cases | +| 18–22 discovery/history | Claude/Codex root fixtures and `sessions_tests.rs` legacy/current/dormant cases; the live-tree source `local_tree_sessions` in `model_tests.rs` | | 23–24 parity audit | three timestamped SHA ledgers and evidence matrix in `REFERENCE_AUDIT.md` | | 25 responsive/accessibility | normal/narrow HTML states, semantic labels, keyboard/focus review | | 26 machine snapshot | CLI schema fixtures, stable-id/collision tests, degraded/null semantics, IPC capability test | diff --git a/specs/parity/cockpit-matrix.json b/specs/parity/cockpit-matrix.json index 835fcb8cfa4..29e5f637bda 100644 --- a/specs/parity/cockpit-matrix.json +++ b/specs/parity/cockpit-matrix.json @@ -452,6 +452,12 @@ "source": "app/src/cockpit/model_tests.rs", "name": "stale_inventory_cannot_readd_disconnected_host" }, + { + "id": "remote-topology-invalidation", + "suite": "cockpit-app", + "source": "app/src/cockpit/model_tests.rs", + "name": "connection_changes_invalidate_inflight_scans_but_inventory_changes_do_not" + }, { "id": "tree-hierarchy-contract", "suite": "cockpit-data-spine", @@ -500,6 +506,12 @@ "source": "crates/zaplex_cockpit/src/sessions_tests.rs", "name": "dormant_history_never_enters_live_tree" }, + { + "id": "dormant-history-local-tree", + "suite": "cockpit-app", + "source": "app/src/cockpit/model_tests.rs", + "name": "dormant_account_history_never_enters_local_tree" + }, { "id": "provider-account-boundary", "suite": "cockpit-data-spine", @@ -953,7 +965,7 @@ "state": ["dormant"] }, "capabilities": ["reattach", "transcript", "lifecycle"], - "evidence": ["claude-multiple-accounts", "claude-dormant", "legacy-claude-resumable", "claude-shell-helper-exclusion", "dormant-history-exclusion", "exact-reattach-boundary", "lifecycle-cleanup-recheck"] + "evidence": ["claude-multiple-accounts", "claude-dormant", "legacy-claude-resumable", "claude-shell-helper-exclusion", "dormant-history-exclusion", "dormant-history-local-tree", "exact-reattach-boundary", "lifecycle-cleanup-recheck"] }, { "id": "codex-default-local-waiting", @@ -997,7 +1009,7 @@ "state": ["live"] }, "capabilities": ["launch", "reattach", "transcript", "lifecycle"], - "evidence": ["remote-first-session", "remote-last-session", "remote-stale-inventory", "exact-reattach-boundary", "daemon-default-account", "daemon-route-dimensions", "daemon-binding-lifecycle", "daemon-account-id-opaque", "remote-claude-transcript-projection", "remote-transcript-ui-gate", "remote-transcript-ui-current", "remote-transcript-ui-projection", "remote-transcript-ui-statuses", "remote-transcript-ui-fail-closed", "generated-read-only-source", "generated-read-only-header", "generated-read-only-no-merge", "lifecycle-remote-restart", "remote-launch-transport-first", "remote-launch-inventory-first", "remote-launch-parallel-account", "remote-selected-second-account", "remote-freest-host-scope", "remote-path-boundary", "managed-headroom-policy", "managed-memory-pss", "managed-memory-global-bound", "managed-account-identity-fresh", "managed-project-identity-fresh", "managed-start-identity-fresh", "managed-signal-pidfd", "managed-unexpected-exit-restart", "managed-stopped-restart", "managed-exit-record-bound", "managed-exit-projection", "managed-stopped-projection", "managed-account-label-projection", "managed-client-envelope"] + "evidence": ["remote-first-session", "remote-last-session", "remote-stale-inventory", "remote-topology-invalidation", "exact-reattach-boundary", "daemon-default-account", "daemon-route-dimensions", "daemon-binding-lifecycle", "daemon-account-id-opaque", "remote-claude-transcript-projection", "remote-transcript-ui-gate", "remote-transcript-ui-current", "remote-transcript-ui-projection", "remote-transcript-ui-statuses", "remote-transcript-ui-fail-closed", "generated-read-only-source", "generated-read-only-header", "generated-read-only-no-merge", "lifecycle-remote-restart", "remote-launch-transport-first", "remote-launch-inventory-first", "remote-launch-parallel-account", "remote-selected-second-account", "remote-freest-host-scope", "remote-path-boundary", "managed-headroom-policy", "managed-memory-pss", "managed-memory-global-bound", "managed-account-identity-fresh", "managed-project-identity-fresh", "managed-start-identity-fresh", "managed-signal-pidfd", "managed-unexpected-exit-restart", "managed-stopped-restart", "managed-exit-record-bound", "managed-exit-projection", "managed-stopped-projection", "managed-account-label-projection", "managed-client-envelope"] }, { "id": "remote-codex-waiting-attention",