From 82b69a265b247aed4aaa61d07ea909a3ccdc6315 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:04:28 +0200 Subject: [PATCH 1/8] feat(sidebar): KI-Sessions und KI-Konten als eigene Sidebar-Ansichten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Kontokarten wandern aus dem Cockpit in eine eigene Toolbelt-Ansicht „KI-Konten“ (CockpitAccountsPanel). Der Session-Baum „KI-Sessions“ und die Konten haben jeweils die volle Sidebarhöhe und einen eigenen Scrollbereich; eine lange Sessionliste verdrängt die Kontenübersicht nicht mehr. - Neuer ToolPanelView/LeftPanelAction/LeftPanelDisplayedTab-Eintrag CockpitAccounts; ein gespeichertes „Cockpit“ stellt weiter den Baum her. - Ist eine andere Ansicht aktiv und wartet ein Agent, trägt der Sessions-Eintrag eine statische amberne Marke. - Die w-Sprungtaste gilt auch in der Kontenansicht. - Die 3/5-Höhenbegrenzung entfällt; PRODUCT, TECH, HTML-Spec und README beschreiben die neue Aufteilung. Refs #504 --- app/i18n/de/warp.ftl | 3 +- app/i18n/en/warp.ftl | 3 +- app/src/app_state.rs | 4 + app/src/cockpit/accounts_panel.rs | 584 +++++++++++++++++++++ app/src/cockpit/accounts_panel_tests.rs | 115 ++++ app/src/cockpit/mod.rs | 2 + app/src/cockpit/panel.rs | 567 +------------------- app/src/cockpit/panel_tests.rs | 114 ---- app/src/workspace/mod.rs | 7 +- app/src/workspace/view.rs | 15 +- app/src/workspace/view/left_panel.rs | 96 +++- app/src/workspace/view/left_panel_tests.rs | 39 +- app/src/workspace/view_test.rs | 28 +- docs/ui/README.md | 6 +- docs/ui/cockpit-sidebar-connections.html | 4 +- specs/GH-160/PRODUCT.md | 11 +- specs/GH-160/TECH.md | 12 +- 17 files changed, 931 insertions(+), 679 deletions(-) create mode 100644 app/src/cockpit/accounts_panel.rs create mode 100644 app/src/cockpit/accounts_panel_tests.rs diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index 7b6d3bb3a93..6a11c8eb207 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -16,7 +16,8 @@ workspace-new-session-agent = Agent workspace-new-session-terminal = Terminal # ── Cockpit ────────────────────────────────────────────────────────────────── -workspace-left-panel-cockpit = Cockpit +workspace-left-panel-cockpit = KI-Sessions +workspace-left-panel-cockpit-accounts = KI-Konten workspace-left-panel-cockpit-empty = Keine Claude- oder Codex-Konten gefunden. cockpit-loading = Konten werden geladen… cockpit-scan-failed = Einige Konten ließen sich nicht lesen. diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 207cfd70bbd..a49e0f5a11d 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -2832,7 +2832,8 @@ workspace-left-panel-agent-conversations = Agent conversations workspace-left-panel-ssh-manager = Connections workspace-left-panel-server-file-browser = Server files workspace-left-panel-skill-manager = Skill Manager -workspace-left-panel-cockpit = Cockpit +workspace-left-panel-cockpit = AI sessions +workspace-left-panel-cockpit-accounts = AI accounts cockpit-sessions-waiting-toast = Waiting on you: { $sessions } cockpit-attention-inbox-title = Open items cockpit-attention-inbox-count = { $count -> diff --git a/app/src/app_state.rs b/app/src/app_state.rs index 423f70e00c6..b1cd86a48cb 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -714,7 +714,10 @@ pub enum LeftPanelDisplayedTab { SshManager, ServerFileBrowser, SkillManager, + /// The „KI-Sessions" tree. A snapshot persisted before the accounts became + /// their own view (#504) also carries this variant and restores the tree. Cockpit, + CockpitAccounts, } impl From for LeftPanelDisplayedTab { @@ -728,6 +731,7 @@ impl From for LeftPanelDisplayedTab { ToolPanelView::ServerFileBrowser => LeftPanelDisplayedTab::ServerFileBrowser, ToolPanelView::SkillManager => LeftPanelDisplayedTab::SkillManager, ToolPanelView::Cockpit => LeftPanelDisplayedTab::Cockpit, + ToolPanelView::CockpitAccounts => LeftPanelDisplayedTab::CockpitAccounts, } } } diff --git a/app/src/cockpit/accounts_panel.rs b/app/src/cockpit/accounts_panel.rs new file mode 100644 index 00000000000..3e90b67cdba --- /dev/null +++ b/app/src/cockpit/accounts_panel.rs @@ -0,0 +1,584 @@ +//! `CockpitAccountsPanel` — the „KI-Konten" sidebar view: provider-explicit +//! account cards with stacked 5-hour and weekly meters plus the fleet total. +//! +//! It is its own toolbelt entry beside the live session tree (#504), so a long +//! session inventory can never push the accounts out of reach. Both views read +//! the same `CockpitModel`; neither owns a second copy of the data. + +use std::collections::HashMap; + +use warp_core::ui::appearance::Appearance; +use warp_core::ui::theme::color::internal_colors; +use warpui::elements::{ + ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, + CornerRadius, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, + Fill as ElementFill, Flex, Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, + Rect, ScrollbarWidth, Shrinkable, Text, +}; +use warpui::{ + accessibility::{AccessibilityContent, WarpA11yRole}, + AppContext, BlurContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, + ViewContext, ViewHandle, +}; +use zaplex_cockpit::{format_cost, heat_pct_label_with_provenance, AccountUsage, UsageProvenance}; + +use crate::cockpit::account_identity; +use crate::cockpit::model::{CockpitEvent, CockpitModel}; +use crate::cockpit::panel::{account_count_presentation, CockpitPanel, CARD_PADDING, CARD_SPACING}; +use crate::cockpit::style::{utilisation_coloru, utilisation_track, zone_card, BLOCK_RADIUS}; +use crate::view_components::action_button::{ActionButton, ButtonSize, PaneHeaderTheme}; + +const HEAT_BAR_HEIGHT: f32 = 6.0; + +/// Events the accounts view emits toward the workspace (via the left panel). +pub enum CockpitAccountsPanelEvent { + /// Open the cockpit pane in the main area: `None` = the fleet dashboard + /// (every account), `Some(account.key)` = that account's own pane. + /// + /// The key travels with the request because the pane IS the account — + /// opening dedupes on it, so two accounts open two panes rather than one + /// dashboard that can only look at whichever was clicked last. + OpenCockpitPane(Option), +} + +enum FleetTotalButtonEvent { + Activated, +} + +#[derive(Clone, Debug)] +enum FleetTotalButtonAction { + Activate, +} + +fn is_fleet_total_activation_keystroke(keystroke: &warpui::keymap::Keystroke) -> bool { + !keystroke.cmd + && !keystroke.ctrl + && !keystroke.alt + && !keystroke.shift + && !keystroke.meta + && matches!(keystroke.key.as_str(), "enter" | "numpadenter" | " ") +} + +struct FleetTotalButton { + button: ViewHandle, + label: String, + #[cfg(test)] + activation_count: usize, +} + +impl FleetTotalButton { + fn new(ctx: &mut ViewContext) -> Self { + let button = ctx.add_typed_action_view(|_| { + ActionButton::new("", PaneHeaderTheme) + .with_size(ButtonSize::XSmall) + .on_click(|ctx| ctx.dispatch_typed_action(FleetTotalButtonAction::Activate)) + }); + Self { + button, + label: String::new(), + #[cfg(test)] + activation_count: 0, + } + } + + fn set_label(&mut self, label: String, ctx: &mut ViewContext) { + self.label.clone_from(&label); + self.button + .update(ctx, |button, ctx| button.set_label(label, ctx)); + ctx.notify(); + } +} + +impl Entity for FleetTotalButton { + type Event = FleetTotalButtonEvent; +} + +impl View for FleetTotalButton { + fn ui_name() -> &'static str { + "FleetTotalButton" + } + + fn accessibility_contents(&self, _ctx: &AppContext) -> Option { + Some(AccessibilityContent::new_without_help( + self.label.clone(), + WarpA11yRole::ButtonRole, + )) + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.button + .update(ctx, |button, ctx| button.set_active(true, ctx)); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.button + .update(ctx, |button, ctx| button.set_active(false, ctx)); + } + } + + fn render(&self, _app: &AppContext) -> Box { + EventHandler::new(ChildView::new(&self.button).finish()) + .on_keydown(|ctx, _, keystroke| { + if is_fleet_total_activation_keystroke(keystroke) { + ctx.dispatch_typed_action(FleetTotalButtonAction::Activate); + DispatchEventResult::StopPropagation + } else { + DispatchEventResult::PropagateToParent + } + }) + .finish() + } +} + +impl TypedActionView for FleetTotalButton { + type Action = FleetTotalButtonAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + FleetTotalButtonAction::Activate => { + #[cfg(test)] + { + self.activation_count += 1; + } + ctx.focus_self(); + ctx.emit(FleetTotalButtonEvent::Activated); + } + } + } +} + +pub struct CockpitAccountsPanel { + scroll_state: ClippedScrollStateHandle, + /// Hover state per account card (key = account `key`). The whole card is a + /// click target that opens the roomy dashboard pane. + card_states: HashMap, + /// Semantic button for the „KI-KONTEN" header's fleet total — the cross-account + /// spend figure doubles as the entry point to the fleet pane (spec v3 §S1). + fleet_total_button: ViewHandle, + /// Hover/click state for the "try again" retry (the loading / scan-failed / + /// empty placeholder). A **stable** handle: `Hoverable` tracks mouse-down in + /// it, so a fresh one each render would drop the click. + rescan_btn: MouseStateHandle, +} + +impl CockpitAccountsPanel { + pub fn new(ctx: &mut ViewContext) -> Self { + let fleet_total_button = ctx.add_typed_action_view(FleetTotalButton::new); + ctx.subscribe_to_view(&fleet_total_button, |_, _, event, ctx| match event { + FleetTotalButtonEvent::Activated => { + ctx.dispatch_typed_action(&CockpitAccountsPanelAction::OpenDashboardPane) + } + }); + // Re-render on theme change and whenever the snapshot updates. + ctx.subscribe_to_model(&Appearance::handle(ctx), |_, _, _, ctx| ctx.notify()); + ctx.subscribe_to_model(&CockpitModel::handle(ctx), |me, _, event, ctx| { + if matches!(event, CockpitEvent::Updated) { + me.sync_card_states(ctx); + me.sync_fleet_total_button(ctx); + ctx.notify(); + } + }); + let mut me = Self { + scroll_state: ClippedScrollStateHandle::default(), + card_states: HashMap::new(), + fleet_total_button, + rescan_btn: MouseStateHandle::default(), + }; + me.sync_card_states(ctx); + me.sync_fleet_total_button(ctx); + me + } + + fn sync_fleet_total_button(&self, ctx: &mut ViewContext) { + let fleet_today = CockpitModel::as_ref(ctx) + .snapshot() + .accounts + .iter() + .map(|account| account.today.cost_usd) + .sum::(); + let label = crate::t!( + "cockpit-header-today-total", + today = format_cost(fleet_today) + ); + self.fleet_total_button + .update(ctx, |button, ctx| button.set_label(label.to_string(), ctx)); + } + + /// Card hover handles, keyed by account `key` (one stable handle per card + /// across renders); drop handles of accounts that disappeared. + fn sync_card_states(&mut self, ctx: &mut ViewContext) { + let acct_keys: std::collections::HashSet = CockpitModel::as_ref(ctx) + .snapshot() + .accounts + .iter() + .map(|a| a.account.key.clone()) + .collect(); + self.card_states.retain(|k, _| acct_keys.contains(k)); + for key in acct_keys { + self.card_states.entry(key).or_default(); + } + } + + /// The placeholder, disambiguated by scan health so an empty account list + /// no longer reads the same whether the first scan is still running, a + /// config/dir failed to load, or there genuinely are no accounts. The failed + /// and genuine-empty cases offer a retry (re-run the scan). + fn render_scan_placeholder( + &self, + health: &zaplex_cockpit::ScanHealth, + enabled: bool, + appearance: &Appearance, + ) -> Box { + use zaplex_cockpit::ScanHealth; + let theme = appearance.theme(); + let family = appearance.ui_font_family(); + let body = appearance.ui_font_body(); + let muted = theme.sub_text_color(theme.surface_2()).into_solid(); + let accent = theme.accent().into_solid(); + // A deliberately-disabled cockpit is neither "empty" nor "loading" — say so, + // and offer no retry (re-scanning cannot help while it is off). + if !enabled { + return CockpitPanel::text( + crate::t!("cockpit-disabled").to_string(), + family, + body, + muted, + ); + } + let (msg, retry) = match health { + ScanHealth::Pending => (crate::t!("cockpit-loading").to_string(), false), + ScanHealth::Degraded(_) => (crate::t!("cockpit-scan-failed").to_string(), true), + ScanHealth::Loaded => ( + crate::t!("workspace-left-panel-cockpit-empty").to_string(), + true, + ), + }; + let msg_el = CockpitPanel::text(msg, family, body, muted); + if !retry { + return msg_el; + } + let retry_el = Hoverable::new(self.rescan_btn.clone(), move |mouse| { + let c = if mouse.is_hovered() { muted } else { accent }; + Text::new_inline(crate::t!("cockpit-retry").to_string(), family, body) + .with_color(c) + .finish() + }) + .with_cursor(warpui::platform::Cursor::PointingHand) + .on_click(|ctx, _, _| ctx.dispatch_typed_action(CockpitAccountsPanelAction::Rescan)) + .finish(); + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(8.0) + .with_child(msg_el) + .with_child(retry_el) + .finish() + } + + /// A labelled heat bar: `5h [▓▓▓░░] 62%`, coloured by band. Estimate-driven + /// bars carry a subtle `~` on the percentage (C3b provenance); real numbers + /// get no extra chrome. + fn heat_bar( + &self, + label: &str, + fraction: f64, + provenance: UsageProvenance, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let family = appearance.ui_font_family(); + let size = appearance.ui_font_body(); + let muted = theme.sub_text_color(theme.surface_1()).into_solid(); + // Utilisation is not attention: one shared rule (spec v3 §1.2) — calm + // theme text, with the theme error role only at the "fast voll" threshold. + // The bar's fill carries the level; color only flags "nearly full". + let bar_color = utilisation_coloru(fraction, appearance); + let track = utilisation_track(fraction, HEAT_BAR_HEIGHT, bar_color, appearance); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(6.0) + .with_child(CockpitPanel::text(label.to_string(), family, size, muted)) + .with_child(track) + .with_child(CockpitPanel::text( + heat_pct_label_with_provenance(fraction, provenance), + family, + size, + bar_color, + )) + .with_main_axis_size(MainAxisSize::Max) + .finish() + } + + fn render_card( + &self, + acct: &AccountUsage, + is_selected: bool, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let family = appearance.ui_font_family(); + let provider_size = appearance.ui_font_body_large(); + let identity_size = appearance.ui_font_footnote(); + let main = theme.main_text_color(theme.surface_1()).into_solid(); + let muted = theme.sub_text_color(theme.surface_1()).into_solid(); + let identity = account_identity(&acct.account); + + // Provider is the stable headline on every account surface. The themed + // accent mark is supplementary; the provider name remains visible. + let header = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(6.0) + .with_child( + ConstrainedBox::new( + Rect::new() + .with_background_color(theme.accent().into_solid()) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) + .finish(), + ) + .with_width(12.0) + .with_height(12.0) + .finish(), + ) + .with_child( + Shrinkable::new( + 1.0, + CockpitPanel::text(identity.provider.to_string(), family, provider_size, main), + ) + .finish(), + ) + .finish(); + + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(CARD_SPACING) + .with_child(header); + if !identity.subline.is_empty() { + col = col.with_child(CockpitPanel::identity_text( + identity.subline, + family, + identity_size, + muted, + )); + } + col = col + .with_child(self.heat_bar( + &crate::t!("cockpit-meter-5h"), + acct.heat, + acct.provenance, + appearance, + )) + .with_child(self.heat_bar( + &crate::t!("cockpit-meter-week"), + acct.heat_week, + acct.provenance, + appearance, + )); + + // A flat account block inside the AI-Accounts zone-card — no per-card + // container chrome (emphasis via content + spacing, spec §2.1). The whole + // block selects the account → opens the pane focused on it (WS4 S5). + // A selected block carries a stable fill; hover adds a subtle fill — + // colour only, never layout (spec §2.7). + let col_el = col.finish(); + let handle = self + .card_states + .get(&acct.account.key) + .cloned() + .unwrap_or_default(); + let key = acct.account.key.clone(); + Hoverable::new(handle, move |mouse| { + let mut c = Container::new(col_el) + .with_uniform_padding(CARD_PADDING) + .with_margin_bottom(CARD_SPACING) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(BLOCK_RADIUS))); + if is_selected { + c = c.with_background(internal_colors::fg_overlay_2(theme)); + } else if mouse.is_hovered() { + c = c.with_background(internal_colors::fg_overlay_1(theme)); + } + c.finish() + }) + .with_cursor(warpui::platform::Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(CockpitAccountsPanelAction::SelectAccount(key.clone())) + }) + .finish() + } + + /// The „KI-KONTEN" header: label + count, plus the **fleet total** — the one + /// cross-account number (spec v3 §S1). + /// + /// The Maximize icon is gone, but the *fleet view it opened* is not: an + /// account pane can only ever show its own account, so if this number and its + /// entry point both vanished, cross-account spend would have no home at all — + /// a regression, not a decluttering. The total therefore stays visible and + /// **is itself the affordance**: clicking it opens the fleet pane. One + /// element, two jobs, no extra chrome. + fn render_header(&self, snapshot_len: usize, appearance: &Appearance) -> Box { + CockpitPanel::render_zone_header( + crate::t!("cockpit-zone-accounts").to_string(), + Some(snapshot_len), + Some(ChildView::new(&self.fleet_total_button).finish()), + appearance.theme().surface_1(), + appearance, + ) + } +} + +impl View for CockpitAccountsPanel { + fn ui_name() -> &'static str { + "CockpitAccountsPanel" + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() && !CockpitModel::as_ref(ctx).snapshot().accounts.is_empty() + { + ctx.focus(&self.fleet_total_button); + } + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + // A disabled cockpit clears its snapshot to empty; the placeholder must say + // "disabled", not "no accounts" (spec: the empty state is only for the real one). + let enabled = *crate::cockpit::settings::CockpitSettings::as_ref(app).enabled; + let snapshot = CockpitModel::as_ref(app).snapshot().clone(); + + let mut content = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + + // A degraded scan with some accounts present: the list may be missing others + // (e.g. a broken Codex sign-in). Warn above the accounts; the empty case shows + // this in its own placeholder instead. + if !snapshot.accounts.is_empty() + && matches!(snapshot.health, zaplex_cockpit::ScanHealth::Degraded(_)) + { + content = content.with_child( + Container::new(self.render_scan_placeholder(&snapshot.health, enabled, appearance)) + .with_uniform_padding(CARD_PADDING) + .with_margin_bottom(CARD_SPACING * 2.0) + .finish(), + ); + } + + // One flat zone-card holding the fleet-usage header + one flat block per + // account. No accounts → a calm hint under the header instead. + if snapshot.accounts.is_empty() { + // Keep the section header, but show zero only after a successful scan. + // Pending or degraded discovery is unknown rather than empty. + let empty = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_child( + Container::new(CockpitPanel::render_zone_header( + crate::t!("cockpit-zone-accounts").to_string(), + account_count_presentation(&snapshot.health, 0), + None, + theme.surface_2(), + appearance, + )) + .with_margin_bottom(CARD_SPACING * 2.0) + .finish(), + ) + .with_child(self.render_scan_placeholder(&snapshot.health, enabled, appearance)); + content = content.with_child( + Container::new(empty.finish()) + .with_uniform_padding(CARD_PADDING) + .finish(), + ); + } else { + let selected = CockpitModel::as_ref(app) + .selected_account() + .map(str::to_string); + let mut accounts = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_child( + Container::new(self.render_header(snapshot.accounts.len(), appearance)) + .with_margin_bottom(CARD_SPACING * 2.0) + .finish(), + ); + for acct in &snapshot.accounts { + let is_selected = selected.as_deref() == Some(acct.account.key.as_str()); + accounts = accounts.with_child(self.render_card(acct, is_selected, appearance)); + } + content = content.with_child( + zone_card(accounts.finish(), appearance) + .with_uniform_padding(CARD_PADDING) + .finish(), + ); + } + + // The accounts view owns the full sidebar height with its own scroll + // state, independent of how many sessions the tree view holds (#504). + let scroll = ClippedScrollable::vertical( + self.scroll_state.clone(), + content.finish(), + ScrollbarWidth::Auto, + theme.disabled_text_color(theme.surface_2()).into(), + theme.main_text_color(theme.surface_2()).into(), + ElementFill::None, + ) + .with_overlayed_scrollbar() + .finish(); + + Container::new(scroll) + .with_uniform_padding(CARD_PADDING) + .with_background(theme.surface_2()) + .finish() + } +} + +impl Entity for CockpitAccountsPanel { + type Event = CockpitAccountsPanelEvent; +} + +/// Accounts-view actions (routed back into the view by the action system). +#[derive(Clone, Debug)] +pub enum CockpitAccountsPanelAction { + OpenDashboardPane, + /// Select an account (its `account.key`) → open (or focus) that account's + /// own pane and carry a stable highlight in the sidebar. A second click + /// focuses the pane; it does not de-select. + SelectAccount(String), + /// Re-run the account scan — the retry on the loading/scan-failed/empty + /// placeholder. + Rescan, +} + +impl TypedActionView for CockpitAccountsPanel { + type Action = CockpitAccountsPanelAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + CockpitAccountsPanelAction::OpenDashboardPane => { + ctx.emit(CockpitAccountsPanelEvent::OpenCockpitPane(None)); + } + CockpitAccountsPanelAction::SelectAccount(key) => { + // Mark it selected (the sidebar highlight follows), then open — + // or focus — that account's own pane. Clicking the same card + // again lands here too and simply focuses the pane it already + // has: the selection no longer toggles off underneath it. + let key = key.clone(); + CockpitModel::handle(ctx) + .update(ctx, |model, ctx| model.select_account(key.clone(), ctx)); + ctx.emit(CockpitAccountsPanelEvent::OpenCockpitPane(Some(key))); + } + CockpitAccountsPanelAction::Rescan => { + CockpitModel::handle(ctx).update(ctx, |model, ctx| model.rescan(ctx)); + } + } + } +} + +#[cfg(test)] +#[path = "accounts_panel_tests.rs"] +mod tests; diff --git a/app/src/cockpit/accounts_panel_tests.rs b/app/src/cockpit/accounts_panel_tests.rs new file mode 100644 index 00000000000..b04d3cb6b0c --- /dev/null +++ b/app/src/cockpit/accounts_panel_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use pathfinder_geometry::vector::vec2f; +use std::cell::RefCell; +use std::rc::Rc; +use warpui::platform::WindowStyle; +use warpui::{App, Event, Presenter, WindowInvalidation}; + +use crate::test_util::settings::initialize_settings_for_tests; + +#[test] +fn fleet_total_button_accepts_button_activation_keys_only() { + for key in ["enter", "numpadenter", " "] { + assert!(is_fleet_total_activation_keystroke( + &warpui::keymap::Keystroke { + key: key.to_string(), + ..Default::default() + } + )); + } + assert!(!is_fleet_total_activation_keystroke( + &warpui::keymap::Keystroke { + key: "right".to_string(), + ..Default::default() + } + )); + assert!(!is_fleet_total_activation_keystroke( + &warpui::keymap::Keystroke { + ctrl: true, + key: " ".to_string(), + ..Default::default() + } + )); +} + +#[test] +fn focused_fleet_total_button_handles_real_unmodified_keydown_forms_only() { + App::test((), |mut app| async move { + crate::i18n::init(Some("en")); + initialize_settings_for_tests(&mut app); + app.add_singleton_model(|_| Appearance::mock()); + let (window_id, button) = app.add_window(WindowStyle::NotStealFocus, |ctx| { + let button = FleetTotalButton::new(ctx); + ctx.focus_self(); + button + }); + let presenter = Rc::new(RefCell::new(Presenter::new(window_id))); + let render = |app: &mut App| { + app.update(|ctx| { + presenter.borrow_mut().invalidate( + WindowInvalidation { + updated: ctx.view_ids_for_window(window_id).into_iter().collect(), + ..Default::default() + }, + ctx, + ); + presenter + .borrow_mut() + .build_scene(vec2f(250.0, 80.0), 1.0, None, ctx); + }); + }; + let press = |app: &mut App, keystroke: warpui::keymap::Keystroke, chars: &str| { + app.update(|ctx| { + ctx.simulate_window_event( + Event::KeyDown { + keystroke, + chars: chars.to_string(), + details: warpui::event::KeyEventDetails::default(), + is_composing: false, + }, + window_id, + presenter.clone(), + ) + }) + }; + + render(&mut app); + assert!(press( + &mut app, + warpui::keymap::Keystroke { + key: "enter".to_string(), + ..Default::default() + }, + "\r", + )); + render(&mut app); + assert!(press( + &mut app, + warpui::keymap::Keystroke { + key: "numpadenter".to_string(), + ..Default::default() + }, + "\r", + )); + render(&mut app); + assert!(press( + &mut app, + warpui::keymap::Keystroke { + key: " ".to_string(), + ..Default::default() + }, + " ", + )); + render(&mut app); + let _ = press( + &mut app, + warpui::keymap::Keystroke { + shift: true, + key: "enter".to_string(), + ..Default::default() + }, + "\r", + ); + button.read(&app, |button, _| assert_eq!(button.activation_count, 3)); + }); +} diff --git a/app/src/cockpit/mod.rs b/app/src/cockpit/mod.rs index 24d758e3271..b20800b317a 100644 --- a/app/src/cockpit/mod.rs +++ b/app/src/cockpit/mod.rs @@ -4,6 +4,7 @@ //! Increment 1: data only (no UI). The account cards / heat bars / cost UI that //! subscribe to `CockpitEvent::Updated` land in Increment 2 (`app/src/cockpit/…`). +pub mod accounts_panel; pub mod ambient; pub mod capabilities; pub mod favorites; @@ -24,6 +25,7 @@ pub mod style; pub mod tailscale; pub mod transcript_view; +pub use accounts_panel::CockpitAccountsPanel; pub use ambient::AttentionDriver; pub use model::CockpitModel; pub use pane::CockpitPaneView; diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index a8ccbe0b263..6613101f6f6 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -1,6 +1,7 @@ -//! `CockpitPanel` — the compact Cockpit sidebar: a live -//! `Host → Project → PTY Session → Agent` tree followed by provider-explicit -//! account cards. The roomy full dashboard remains the main-area pane. +//! `CockpitPanel` — the „KI-Sessions" sidebar view: the live +//! `Host → Project → PTY Session → Agent` tree with the full sidebar height. +//! The account cards are their own toolbelt view (`CockpitAccountsPanel`, +//! #504); the roomy full dashboard remains the main-area pane. use std::collections::HashMap; use std::path::Path; @@ -14,47 +15,41 @@ use pathfinder_geometry::{ }; use warp_core::ui::appearance::Appearance; use warp_core::ui::color::coloru_with_opacity; -use warp_core::ui::theme::{color::internal_colors, Fill}; +use warp_core::ui::theme::Fill; use warpui::elements::{ - Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, - Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, - Fill as ElementFill, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, - OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Point, Radius, - Rect, ScrollbarWidth, Shrinkable, Stack, Text, + Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, + CornerRadius, CrossAxisAlignment, Element, Fill as ElementFill, Flex, Hoverable, + MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, ParentAnchor, + ParentElement, ParentOffsetBounds, Point, Radius, ScrollbarWidth, Shrinkable, Stack, Text, }; use warpui::platform::Cursor; use warpui::text_layout::ClipConfig; use warpui::windowing::{StateEvent, WindowManager}; use warpui::{ - accessibility::{AccessibilityContent, WarpA11yRole}, - AfterLayoutContext, AppContext, BlurContext, Entity, EventContext, FocusContext, LayoutContext, - PaintContext, SingletonEntity, SizeConstraint, TypedActionView, View, ViewContext, ViewHandle, - WindowId, + AfterLayoutContext, AppContext, Entity, EventContext, LayoutContext, PaintContext, + SingletonEntity, SizeConstraint, TypedActionView, View, ViewContext, WindowId, }; use zaplex_cockpit::{ - fleet_conductor_session_count, format_cost, format_relative, group_project_sessions, - heat_pct_label_with_provenance, host_conductor_session_count, host_ident, session_glyph, - session_key, AccountUsage, AgentInventoryStatus, ConductorSession, FleetTree, HostAvailability, - HostNode, Provider, SessionSnapshot, SessionState, TaskState, TaskStatus, UsageProvenance, + fleet_conductor_session_count, format_relative, group_project_sessions, + host_conductor_session_count, host_ident, session_glyph, session_key, AgentInventoryStatus, + ConductorSession, FleetTree, HostAvailability, HostNode, Provider, SessionSnapshot, + SessionState, TaskState, TaskStatus, }; -use crate::cockpit::account_identity; use crate::cockpit::fleet_details::ManagedFleetInventory; use crate::cockpit::model::{CockpitEvent, CockpitModel}; use crate::cockpit::style::{ - attention_coloru, glyph_cell, hover_row, provider_label, status_dot_coloru, utilisation_coloru, - utilisation_track, zone_card, BLOCK_RADIUS, GLYPH_COL_WIDTH, + attention_coloru, glyph_cell, hover_row, provider_label, status_dot_coloru, zone_card, + GLYPH_COL_WIDTH, }; use crate::settings::AccessibilitySettings; use crate::ui_components::icons; use crate::ui_components::window_focus_dimming::WindowFocusDimming; -use crate::view_components::action_button::{ActionButton, ButtonSize, PaneHeaderTheme}; use crate::WorkspaceAction; -const CARD_PADDING: f32 = 8.0; -const CARD_SPACING: f32 = 4.0; +pub(super) const CARD_PADDING: f32 = 8.0; +pub(super) const CARD_SPACING: f32 = 4.0; const TREE_DEPTH_INDENT: f32 = 16.0; -const HEAT_BAR_HEIGHT: f32 = 6.0; const TASK_PEEK_WIDTH: f32 = 390.0; pub(super) const TASK_PEEK_DELAY: Duration = Duration::from_millis(350); const WAITING_PULSE_PERIOD: Duration = Duration::from_millis(1600); @@ -79,7 +74,7 @@ fn container_count_presentation( }) } -fn account_count_presentation( +pub(super) fn account_count_presentation( health: &zaplex_cockpit::ScanHealth, account_count: usize, ) -> Option { @@ -228,130 +223,9 @@ fn host_display_label(host: &HostNode, removed_label: &str, unverified_label: &s } } -/// Events the sidebar emits toward the workspace (via the left panel). -pub enum CockpitPanelEvent { - /// Open the cockpit pane in the main area: `None` = the fleet dashboard - /// (every account), `Some(account.key)` = that account's own pane. - /// - /// The key travels with the request because the pane IS the account — - /// opening dedupes on it, so two accounts open two panes rather than one - /// dashboard that can only look at whichever was clicked last. - OpenCockpitPane(Option), -} - -enum FleetTotalButtonEvent { - Activated, -} - -#[derive(Clone, Debug)] -enum FleetTotalButtonAction { - Activate, -} - -fn is_fleet_total_activation_keystroke(keystroke: &warpui::keymap::Keystroke) -> bool { - !keystroke.cmd - && !keystroke.ctrl - && !keystroke.alt - && !keystroke.shift - && !keystroke.meta - && matches!(keystroke.key.as_str(), "enter" | "numpadenter" | " ") -} - -struct FleetTotalButton { - button: ViewHandle, - label: String, - #[cfg(test)] - activation_count: usize, -} - -impl FleetTotalButton { - fn new(ctx: &mut ViewContext) -> Self { - let button = ctx.add_typed_action_view(|_| { - ActionButton::new("", PaneHeaderTheme) - .with_size(ButtonSize::XSmall) - .on_click(|ctx| ctx.dispatch_typed_action(FleetTotalButtonAction::Activate)) - }); - Self { - button, - label: String::new(), - #[cfg(test)] - activation_count: 0, - } - } - - fn set_label(&mut self, label: String, ctx: &mut ViewContext) { - self.label.clone_from(&label); - self.button - .update(ctx, |button, ctx| button.set_label(label, ctx)); - ctx.notify(); - } -} - -impl Entity for FleetTotalButton { - type Event = FleetTotalButtonEvent; -} - -impl View for FleetTotalButton { - fn ui_name() -> &'static str { - "FleetTotalButton" - } - - fn accessibility_contents(&self, _ctx: &AppContext) -> Option { - Some(AccessibilityContent::new_without_help( - self.label.clone(), - WarpA11yRole::ButtonRole, - )) - } - - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - self.button - .update(ctx, |button, ctx| button.set_active(true, ctx)); - } - } - - fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { - if blur_ctx.is_self_blurred() { - self.button - .update(ctx, |button, ctx| button.set_active(false, ctx)); - } - } - - fn render(&self, _app: &AppContext) -> Box { - EventHandler::new(ChildView::new(&self.button).finish()) - .on_keydown(|ctx, _, keystroke| { - if is_fleet_total_activation_keystroke(keystroke) { - ctx.dispatch_typed_action(FleetTotalButtonAction::Activate); - DispatchEventResult::StopPropagation - } else { - DispatchEventResult::PropagateToParent - } - }) - .finish() - } -} - -impl TypedActionView for FleetTotalButton { - type Action = FleetTotalButtonAction; - - fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { - match action { - FleetTotalButtonAction::Activate => { - #[cfg(test)] - { - self.activation_count += 1; - } - ctx.focus_self(); - ctx.emit(FleetTotalButtonEvent::Activated); - } - } - } -} - pub struct CockpitPanel { window_id: WindowId, session_scroll_state: ClippedScrollStateHandle, - account_scroll_state: ClippedScrollStateHandle, /// Hover/click state per Conductor session row (complete host + provider + /// account + conversation identity), synced against the unified inventory. /// Clicking a row attaches the agent. @@ -360,24 +234,14 @@ pub struct CockpitPanel { /// Tooltip hover state for each agent status glyph. Kept separate from the /// clickable row and task-peek handles so only the glyph owns this tooltip. conductor_row_glyph_states: HashMap, - /// Hover state per account card (key = account `key`). The whole card is a - /// click target that opens the roomy dashboard pane. - card_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. conductor_host_glyph_states: HashMap, /// Explicit host expansion overrides. Absent means expanded. expanded_hosts: HashMap, - /// Semantic button for the „KI-KONTEN" header's fleet total — the cross-account - /// spend figure doubles as the entry point to the fleet pane (spec v3 §S1). - fleet_total_button: ViewHandle, /// Stable hover state for the waiting-summary glyph in the Sessions header. conductor_attention_state: MouseStateHandle, - /// Hover/click state for the account-zone "try again" retry (the loading / - /// scan-failed / empty placeholder). A **stable** handle: `Hoverable` tracks - /// mouse-down in it, so a fresh one each render would drop the click. - rescan_btn: MouseStateHandle, /// Hover/click state of each **project group header** (the collapsible /// Host → Projekt → Session level), keyed by `project_key`. Clicking the /// header folds/unfolds that project's sessions. @@ -470,12 +334,6 @@ pub(super) fn task_activity_label(task_state: Option<&TaskState>, relative: &str impl CockpitPanel { pub fn new(ctx: &mut ViewContext) -> Self { - let fleet_total_button = ctx.add_typed_action_view(FleetTotalButton::new); - ctx.subscribe_to_view(&fleet_total_button, |_, _, event, ctx| match event { - FleetTotalButtonEvent::Activated => { - ctx.dispatch_typed_action(&CockpitPanelAction::OpenDashboardPane) - } - }); // Re-render on theme change and whenever the snapshot updates. ctx.subscribe_to_model(&Appearance::handle(ctx), |_, _, _, ctx| ctx.notify()); ctx.subscribe_to_model(&AccessibilitySettings::handle(ctx), |_, _, _, ctx| { @@ -484,7 +342,6 @@ impl CockpitPanel { ctx.subscribe_to_model(&CockpitModel::handle(ctx), |me, _, event, ctx| { if matches!(event, CockpitEvent::Updated) { me.sync_conductor_states(ctx); - me.sync_fleet_total_button(ctx); ctx.notify(); } }); @@ -499,42 +356,22 @@ impl CockpitPanel { let mut me = Self { window_id: ctx.window_id(), session_scroll_state: ClippedScrollStateHandle::default(), - account_scroll_state: ClippedScrollStateHandle::default(), conductor_row_states: HashMap::new(), conductor_peek_states: HashMap::new(), conductor_row_glyph_states: HashMap::new(), - card_states: HashMap::new(), conductor_host_states: HashMap::new(), conductor_host_glyph_states: HashMap::new(), expanded_hosts: HashMap::new(), - fleet_total_button, conductor_attention_state: MouseStateHandle::default(), - rescan_btn: MouseStateHandle::default(), conductor_project_states: HashMap::new(), expanded_projects: HashMap::new(), conductor_session_states: HashMap::new(), expanded_sessions: HashMap::new(), }; me.sync_conductor_states(ctx); - me.sync_fleet_total_button(ctx); me } - fn sync_fleet_total_button(&self, ctx: &mut ViewContext) { - let fleet_today = CockpitModel::as_ref(ctx) - .snapshot() - .accounts - .iter() - .map(|account| account.today.cost_usd) - .sum::(); - let label = crate::t!( - "cockpit-header-today-total", - today = format_cost(fleet_today) - ); - self.fleet_total_button - .update(ctx, |button, ctx| button.set_label(label.to_string(), ctx)); - } - /// Keep one stable row handle per live fleet session (hover needs a stable /// handle across renders); drop handles of sessions that disappeared. fn sync_conductor_states(&mut self, ctx: &mut ViewContext) { @@ -606,18 +443,6 @@ impl CockpitPanel { self.conductor_peek_states.entry(key.clone()).or_default(); self.conductor_row_glyph_states.entry(key).or_default(); } - // Card hover handles, keyed by account `key` (one stable handle per card - // across renders); drop handles of accounts that disappeared. - let acct_keys: std::collections::HashSet = CockpitModel::as_ref(ctx) - .snapshot() - .accounts - .iter() - .map(|a| a.account.key.clone()) - .collect(); - self.card_states.retain(|k, _| acct_keys.contains(k)); - for key in acct_keys { - self.card_states.entry(key).or_default(); - } // Connected host handles and explicit expansion overrides. self.conductor_host_states .retain(|key, _| host_keys.contains(key)); @@ -648,7 +473,7 @@ impl CockpitPanel { } } - fn text( + pub(super) fn text( s: String, family: warpui::fonts::FamilyId, size: f32, @@ -657,7 +482,7 @@ impl CockpitPanel { Text::new_inline(s, family, size).with_color(color).finish() } - fn identity_text( + pub(super) fn identity_text( s: String, family: warpui::fonts::FamilyId, size: f32, @@ -696,195 +521,6 @@ impl CockpitPanel { ) } - /// The account-zone placeholder, disambiguated by scan health so an empty - /// account list no longer reads the same whether the first scan is still - /// running, a config/dir failed to load, or there genuinely are no accounts. - /// The failed and genuine-empty cases offer a retry (re-run the scan). - fn render_scan_placeholder( - &self, - health: &zaplex_cockpit::ScanHealth, - enabled: bool, - appearance: &Appearance, - ) -> Box { - use zaplex_cockpit::ScanHealth; - let theme = appearance.theme(); - let family = appearance.ui_font_family(); - let body = appearance.ui_font_body(); - let muted = theme.sub_text_color(theme.surface_2()).into_solid(); - let accent = theme.accent().into_solid(); - // A deliberately-disabled cockpit is neither "empty" nor "loading" — say so, - // and offer no retry (re-scanning cannot help while it is off). - if !enabled { - return Self::text( - crate::t!("cockpit-disabled").to_string(), - family, - body, - muted, - ); - } - let (msg, retry) = match health { - ScanHealth::Pending => (crate::t!("cockpit-loading").to_string(), false), - ScanHealth::Degraded(_) => (crate::t!("cockpit-scan-failed").to_string(), true), - ScanHealth::Loaded => ( - crate::t!("workspace-left-panel-cockpit-empty").to_string(), - true, - ), - }; - let msg_el = Self::text(msg, family, body, muted); - if !retry { - return msg_el; - } - let retry_el = Hoverable::new(self.rescan_btn.clone(), move |mouse| { - let c = if mouse.is_hovered() { muted } else { accent }; - Text::new_inline(crate::t!("cockpit-retry").to_string(), family, body) - .with_color(c) - .finish() - }) - .with_cursor(warpui::platform::Cursor::PointingHand) - .on_click(|ctx, _, _| ctx.dispatch_typed_action(CockpitPanelAction::Rescan)) - .finish(); - Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_main_axis_size(MainAxisSize::Min) - .with_spacing(8.0) - .with_child(msg_el) - .with_child(retry_el) - .finish() - } - - /// A labelled heat bar: `5h [▓▓▓░░] 62%`, coloured by band. Estimate-driven - /// bars carry a subtle `~` on the percentage (C3b provenance); real numbers - /// get no extra chrome. - fn heat_bar( - &self, - label: &str, - fraction: f64, - provenance: UsageProvenance, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - let family = appearance.ui_font_family(); - let size = appearance.ui_font_body(); - let muted = theme.sub_text_color(theme.surface_1()).into_solid(); - // Utilisation is not attention: one shared rule (spec v3 §1.2) — calm - // theme text, with the theme error role only at the "fast voll" threshold. - // The bar's fill carries the level; color only flags "nearly full". - let bar_color = utilisation_coloru(fraction, appearance); - let track = utilisation_track(fraction, HEAT_BAR_HEIGHT, bar_color, appearance); - - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(6.0) - .with_child(Self::text(label.to_string(), family, size, muted)) - .with_child(track) - .with_child(Self::text( - heat_pct_label_with_provenance(fraction, provenance), - family, - size, - bar_color, - )) - .with_main_axis_size(MainAxisSize::Max) - .finish() - } - - fn render_card( - &self, - acct: &AccountUsage, - is_selected: bool, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - let family = appearance.ui_font_family(); - let provider_size = appearance.ui_font_body_large(); - let identity_size = appearance.ui_font_footnote(); - let main = theme.main_text_color(theme.surface_1()).into_solid(); - let muted = theme.sub_text_color(theme.surface_1()).into_solid(); - let identity = account_identity(&acct.account); - - // Provider is the stable headline on every account surface. The themed - // accent mark is supplementary; the provider name remains visible. - let header = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_spacing(6.0) - .with_child( - ConstrainedBox::new( - Rect::new() - .with_background_color(theme.accent().into_solid()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) - .finish(), - ) - .with_width(12.0) - .with_height(12.0) - .finish(), - ) - .with_child( - Shrinkable::new( - 1.0, - Self::text(identity.provider.to_string(), family, provider_size, main), - ) - .finish(), - ) - .finish(); - - let mut col = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_main_axis_size(MainAxisSize::Min) - .with_spacing(CARD_SPACING) - .with_child(header); - if !identity.subline.is_empty() { - col = col.with_child(Self::identity_text( - identity.subline, - family, - identity_size, - muted, - )); - } - col = col - .with_child(self.heat_bar( - &crate::t!("cockpit-meter-5h"), - acct.heat, - acct.provenance, - appearance, - )) - .with_child(self.heat_bar( - &crate::t!("cockpit-meter-week"), - acct.heat_week, - acct.provenance, - appearance, - )); - - // A flat account block inside the AI-Accounts zone-card — no per-card - // container chrome (emphasis via content + spacing, spec §2.1). The whole - // block selects the account → opens the pane focused on it (WS4 S5). - // A selected block carries a stable fill; hover adds a subtle fill — - // colour only, never layout (spec §2.7). - let col_el = col.finish(); - let handle = self - .card_states - .get(&acct.account.key) - .cloned() - .unwrap_or_default(); - let key = acct.account.key.clone(); - Hoverable::new(handle, move |mouse| { - let mut c = Container::new(col_el) - .with_uniform_padding(CARD_PADDING) - .with_margin_bottom(CARD_SPACING) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(BLOCK_RADIUS))); - if is_selected { - c = c.with_background(internal_colors::fg_overlay_2(theme)); - } else if mouse.is_hovered() { - c = c.with_background(internal_colors::fg_overlay_1(theme)); - } - c.finish() - }) - .with_cursor(warpui::platform::Cursor::PointingHand) - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(CockpitPanelAction::SelectAccount(key.clone())) - }) - .finish() - } - /// The glanceable **Conductor** for the sidebar: the unified cross-host /// inventory as `Host → Project → Session → Agent`. Local is always present; /// remote roots are supplied only by live daemon connections. Every level @@ -895,7 +531,7 @@ impl CockpitPanel { /// Shared quiet zone header: uppercase label, muted total, and at most one /// trailing aggregate or affordance. The Sessions header uses that slot for /// glyph + needs-attention count, never a repeated status word. - fn render_zone_header( + pub(super) fn render_zone_header( label: String, count: Option, trailing: Option>, @@ -1599,32 +1235,6 @@ impl CockpitPanel { }) .finish() } - - /// The „KI-KONTEN" zone header: label + count like the connections zone, plus - /// the **fleet total** — the one cross-account number (spec v3 §S1). - /// - /// The Maximize icon is gone, but the *fleet view it opened* is not: an - /// account pane can only ever show its own account, so if this number and its - /// entry point both vanished, cross-account spend would have no home at all — - /// a regression, not a decluttering. The total therefore stays visible and - /// **is itself the affordance**: clicking it opens the fleet pane. One - /// element, two jobs, no extra chrome. - fn render_header(&self, snapshot_len: usize, appearance: &Appearance) -> Box { - Self::render_zone_header( - crate::t!("cockpit-zone-accounts").to_string(), - Some(snapshot_len), - Some(ChildView::new(&self.fleet_total_button).finish()), - appearance.theme().surface_1(), - appearance, - ) - } - - // (No standalone Maximize icon any more — spec v3 §S1. The fleet dashboard it - // opened is still reachable: the KI-KONTEN header's fleet total is now the - // affordance, dispatching `OpenDashboardPane` → the fleet pane (no account - // key). A card click opens that account's own pane instead — an *additional* - // path, never a replacement for the fleet-wide view, which is why the total - // stays a target of its own.) } impl View for CockpitPanel { @@ -1632,26 +1242,15 @@ impl View for CockpitPanel { "CockpitPanel" } - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() && !CockpitModel::as_ref(ctx).snapshot().accounts.is_empty() - { - ctx.focus(&self.fleet_total_button); - } - } - fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); - // A disabled cockpit clears its snapshot to empty; the placeholder must say - // "disabled", not "no accounts" (spec: the empty state is only for the real one). - let enabled = *crate::cockpit::settings::CockpitSettings::as_ref(app).enabled; let reduce_motion = *AccessibilitySettings::as_ref(app).reduce_motion; let animate_waiting = waiting_pulse_should_animate( reduce_motion, WindowFocusDimming::is_window_focused(self.window_id, app), ); - let snapshot = CockpitModel::as_ref(app).snapshot().clone(); let inventory = CockpitModel::as_ref(app).inventory().clone(); let managed_fleet = CockpitModel::as_ref(app).managed_fleet().clone(); // The live object tree remains independent of account discovery: local @@ -1667,74 +1266,9 @@ impl View for CockpitPanel { Flex::column().finish() }; - let mut account_content = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_main_axis_size(MainAxisSize::Min); - - // A degraded scan with some accounts present: the list may be missing others - // (e.g. a broken Codex sign-in). Warn above the accounts; the empty case shows - // this in its own placeholder instead. - if !snapshot.accounts.is_empty() - && matches!(snapshot.health, zaplex_cockpit::ScanHealth::Degraded(_)) - { - account_content = account_content.with_child( - Container::new(self.render_scan_placeholder(&snapshot.health, enabled, appearance)) - .with_uniform_padding(CARD_PADDING) - .with_margin_bottom(CARD_SPACING * 2.0) - .finish(), - ); - } - - // ── AI-Accounts zone-card (below the hosts, spec §2.1). One flat card - // holding the fleet-usage header + one flat block per account. Empty - // accounts show a calm hint instead (a section under the hosts, not the - // whole panel — hosts stay visible without an account). - if snapshot.accounts.is_empty() { - // Keep the section header, but show zero only after a successful scan. - // Pending or degraded discovery is unknown rather than empty. - let empty = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_main_axis_size(MainAxisSize::Min) - .with_child( - Container::new(Self::render_zone_header( - crate::t!("cockpit-zone-accounts").to_string(), - account_count_presentation(&snapshot.health, 0), - None, - theme.surface_2(), - appearance, - )) - .with_margin_bottom(CARD_SPACING * 2.0) - .finish(), - ) - .with_child(self.render_scan_placeholder(&snapshot.health, enabled, appearance)); - account_content = account_content.with_child( - Container::new(empty.finish()) - .with_uniform_padding(CARD_PADDING) - .finish(), - ); - } else { - let selected = CockpitModel::as_ref(app) - .selected_account() - .map(str::to_string); - let mut accounts = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_main_axis_size(MainAxisSize::Min) - .with_child( - Container::new(self.render_header(snapshot.accounts.len(), appearance)) - .with_margin_bottom(CARD_SPACING * 2.0) - .finish(), - ); - for acct in &snapshot.accounts { - let is_selected = selected.as_deref() == Some(acct.account.key.as_str()); - accounts = accounts.with_child(self.render_card(acct, is_selected, appearance)); - } - account_content = account_content.with_child( - zone_card(accounts.finish(), appearance) - .with_uniform_padding(CARD_PADDING) - .finish(), - ); - } - + // The session tree owns the full sidebar height with one scroll state; + // the accounts are a separate toolbelt view and can no longer be pushed + // out of view by a long inventory (#504). let session_scroll = ClippedScrollable::vertical( self.session_scroll_state.clone(), session_content, @@ -1745,29 +1279,8 @@ impl View for CockpitPanel { ) .with_overlayed_scrollbar() .finish(); - let account_scroll = ClippedScrollable::vertical( - self.account_scroll_state.clone(), - account_content.finish(), - ScrollbarWidth::Auto, - theme.disabled_text_color(theme.surface_2()).into(), - theme.main_text_color(theme.surface_2()).into(), - ElementFill::None, - ) - .with_overlayed_scrollbar() - .finish(); - - // The upper and lower zones scroll independently. A long session tree - // receives at most three fifths of the available height, so account - // capacity remains visible without adding another navigation mode. - let body_el = Flex::column() - .with_main_axis_size(MainAxisSize::Max) - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_spacing(CARD_SPACING * 2.0) - .with_child(Shrinkable::new(3.0, session_scroll).finish()) - .with_child(Shrinkable::new(2.0, account_scroll).finish()) - .finish(); - Container::new(body_el) + Container::new(session_scroll) .with_uniform_padding(CARD_PADDING) .with_background(theme.surface_2()) .finish() @@ -1775,13 +1288,12 @@ impl View for CockpitPanel { } impl Entity for CockpitPanel { - type Event = CockpitPanelEvent; + type Event = (); } /// Sidebar actions (routed back into the view by the action system). #[derive(Clone, Debug)] pub enum CockpitPanelAction { - OpenDashboardPane, /// Collapse/expand a connected host root. Absent means expanded. ToggleHost(String), /// Collapse/expand a project group in the Host → Projekt → Session tree, @@ -1790,13 +1302,6 @@ pub enum CockpitPanelAction { ToggleProject(String), /// Collapse/expand a terminal/PTY Session container. Absent means expanded. ToggleSession(String), - /// Select an account (its `account.key`) → open (or focus) that account's - /// own pane and carry a stable highlight in the sidebar. A second click - /// focuses the pane; it does not de-select. - SelectAccount(String), - /// Re-run the account scan — the retry on the loading/scan-failed/empty - /// placeholder. - Rescan, } impl TypedActionView for CockpitPanel { @@ -1804,9 +1309,6 @@ impl TypedActionView for CockpitPanel { fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { - CockpitPanelAction::OpenDashboardPane => { - ctx.emit(CockpitPanelEvent::OpenCockpitPane(None)); - } CockpitPanelAction::ToggleHost(key) => { let current = self.expanded_hosts.get(key).copied().unwrap_or(true); self.expanded_hosts.insert(key.clone(), !current); @@ -1823,19 +1325,6 @@ impl TypedActionView for CockpitPanel { self.expanded_sessions.insert(key.clone(), !current); ctx.notify(); } - CockpitPanelAction::SelectAccount(key) => { - // Mark it selected (the sidebar highlight follows), then open — - // or focus — that account's own pane. Clicking the same card - // again lands here too and simply focuses the pane it already - // has: the selection no longer toggles off underneath it. - let key = key.clone(); - CockpitModel::handle(ctx) - .update(ctx, |model, ctx| model.select_account(key.clone(), ctx)); - ctx.emit(CockpitPanelEvent::OpenCockpitPane(Some(key))); - } - CockpitPanelAction::Rescan => { - CockpitModel::handle(ctx).update(ctx, |model, ctx| model.rescan(ctx)); - } } } } diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index 27a7f0f5509..50c14f2f9b0 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -1,120 +1,6 @@ use super::*; -use pathfinder_geometry::vector::vec2f; -use std::cell::RefCell; -use std::rc::Rc; -use warpui::platform::WindowStyle; -use warpui::{App, Event, Presenter, WindowInvalidation}; use zaplex_cockpit::TaskItem; -use crate::test_util::settings::initialize_settings_for_tests; - -#[test] -fn fleet_total_button_accepts_button_activation_keys_only() { - for key in ["enter", "numpadenter", " "] { - assert!(is_fleet_total_activation_keystroke( - &warpui::keymap::Keystroke { - key: key.to_string(), - ..Default::default() - } - )); - } - assert!(!is_fleet_total_activation_keystroke( - &warpui::keymap::Keystroke { - key: "right".to_string(), - ..Default::default() - } - )); - assert!(!is_fleet_total_activation_keystroke( - &warpui::keymap::Keystroke { - ctrl: true, - key: " ".to_string(), - ..Default::default() - } - )); -} - -#[test] -fn focused_fleet_total_button_handles_real_unmodified_keydown_forms_only() { - App::test((), |mut app| async move { - crate::i18n::init(Some("en")); - initialize_settings_for_tests(&mut app); - app.add_singleton_model(|_| Appearance::mock()); - let (window_id, button) = app.add_window(WindowStyle::NotStealFocus, |ctx| { - let button = FleetTotalButton::new(ctx); - ctx.focus_self(); - button - }); - let presenter = Rc::new(RefCell::new(Presenter::new(window_id))); - let render = |app: &mut App| { - app.update(|ctx| { - presenter.borrow_mut().invalidate( - WindowInvalidation { - updated: ctx.view_ids_for_window(window_id).into_iter().collect(), - ..Default::default() - }, - ctx, - ); - presenter - .borrow_mut() - .build_scene(vec2f(250.0, 80.0), 1.0, None, ctx); - }); - }; - let press = |app: &mut App, keystroke: warpui::keymap::Keystroke, chars: &str| { - app.update(|ctx| { - ctx.simulate_window_event( - Event::KeyDown { - keystroke, - chars: chars.to_string(), - details: warpui::event::KeyEventDetails::default(), - is_composing: false, - }, - window_id, - presenter.clone(), - ) - }) - }; - - render(&mut app); - assert!(press( - &mut app, - warpui::keymap::Keystroke { - key: "enter".to_string(), - ..Default::default() - }, - "\r", - )); - render(&mut app); - assert!(press( - &mut app, - warpui::keymap::Keystroke { - key: "numpadenter".to_string(), - ..Default::default() - }, - "\r", - )); - render(&mut app); - assert!(press( - &mut app, - warpui::keymap::Keystroke { - key: " ".to_string(), - ..Default::default() - }, - " ", - )); - render(&mut app); - let _ = press( - &mut app, - warpui::keymap::Keystroke { - shift: true, - key: "enter".to_string(), - ..Default::default() - }, - "\r", - ); - button.read(&app, |button, _| assert_eq!(button.activation_count, 3)); - }); -} - #[test] fn current_task_prefers_in_progress_step() { let state = TaskState { diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index b889f9729ba..44f858791c8 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -680,14 +680,17 @@ pub fn init(app: &mut AppContext) { ), // Conductor `w`-jump: cycle to the next Waiting agent across the whole // fleet and attach it. Scoped to the cockpit surfaces (the roomy pane and - // the sidebar panel) so plain `w` never clobbers word-motion elsewhere. + // the sidebar session and account views) so plain `w` never clobbers + // word-motion elsewhere. EditableBinding::new( "workspace:jump_to_next_waiting", BindingDescription::new(crate::t!("keybinding-desc-workspace-jump-to-next-waiting")), WorkspaceAction::JumpToNextWaiting, ) .with_group(bindings::BindingGroup::Navigation.as_str()) - .with_context_predicate(id!("CockpitPaneView") | id!("CockpitPanel")) + .with_context_predicate( + id!("CockpitPaneView") | id!("CockpitPanel") | id!("CockpitAccountsPanel"), + ) .with_key_binding("w"), EditableBinding::new( "workspace:toggle_left_panel", diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 216066a8825..dda5dd09179 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -2449,7 +2449,13 @@ fn split_launch_menu_items( fn primary_host_navigation_views(cockpit_enabled: bool) -> Vec { if cockpit_enabled { - vec![ToolPanelView::Cockpit, ToolPanelView::SshManager] + // Sessions and accounts are sibling entries (#504): a long session + // inventory must never push the account overview out of reach. + vec![ + ToolPanelView::Cockpit, + ToolPanelView::CockpitAccounts, + ToolPanelView::SshManager, + ] } else { vec![ToolPanelView::SshManager] } @@ -5555,6 +5561,7 @@ impl Workspace { LeftPanelDisplayedTab::ServerFileBrowser => ToolPanelView::ServerFileBrowser, LeftPanelDisplayedTab::SkillManager => ToolPanelView::SkillManager, LeftPanelDisplayedTab::Cockpit => ToolPanelView::Cockpit, + LeftPanelDisplayedTab::CockpitAccounts => ToolPanelView::CockpitAccounts, }; lp.restore_active_view_from_snapshot(active_view, ctx); lp.set_active_pane_group(pane_group.clone(), &self.working_directories_model, ctx); @@ -26369,6 +26376,9 @@ impl Workspace { crate::t!("workspace-left-panel-skill-manager") } ToolPanelView::Cockpit => crate::t!("workspace-left-panel-cockpit"), + ToolPanelView::CockpitAccounts => { + crate::t!("workspace-left-panel-cockpit-accounts") + } } } else { crate::t!("workspace-tools-panel-tooltip") @@ -26439,6 +26449,9 @@ impl Workspace { crate::t!("workspace-left-panel-skill-manager") } ToolPanelView::Cockpit => crate::t!("workspace-left-panel-cockpit"), + ToolPanelView::CockpitAccounts => { + crate::t!("workspace-left-panel-cockpit-accounts") + } } } else { crate::t!("workspace-tools-panel-tooltip") diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index e6c684a3eba..6aa7f23c90a 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -7,9 +7,10 @@ use warp_core::{send_telemetry_from_ctx, ui::Icon, HostId, SessionId}; use warp_util::path::LineAndColumnArg; use warpui::{ elements::{ - resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment, - DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, - ParentElement, Resizable, ResizableStateHandle, Shrinkable, + resizable_state_handle, ChildView, ConstrainedBox, Container, CornerRadius, + CrossAxisAlignment, DragBarSide, Element, Empty, Fill as ElementFill, Flex, + MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Resizable, + ResizableStateHandle, Shrinkable, }, platform::Cursor, ui_components::components::{Coords, UiComponent, UiComponentStyles}, @@ -20,7 +21,10 @@ use warpui::{ use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::skills::{SkillManager, SkillOpenOrigin}; -use crate::cockpit::CockpitPanel; +use crate::cockpit::accounts_panel::CockpitAccountsPanelEvent; +use crate::cockpit::model::{CockpitEvent, CockpitModel}; +use crate::cockpit::style::attention_coloru; +use crate::cockpit::{CockpitAccountsPanel, CockpitPanel}; use crate::code::editor_management::CodeSource; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeEvent; @@ -65,6 +69,7 @@ use crate::{ ui_components::{ buttons::{icon_button, icon_button_with_color}, icons, + red_notification_dot::RedNotificationDot, }, util::bindings::keybinding_name_to_display_string, workspace::WorkspaceAction, @@ -81,6 +86,7 @@ struct MouseStateHandles { server_file_browser_button: MouseStateHandle, skill_manager_button: MouseStateHandle, cockpit_button: MouseStateHandle, + cockpit_accounts_button: MouseStateHandle, } #[derive(Clone, Debug)] @@ -93,6 +99,7 @@ pub enum LeftPanelAction { ServerFileBrowser, SkillManager, Cockpit, + CockpitAccounts, ReturnFromSecondaryView, } @@ -178,6 +185,7 @@ pub enum ToolPanelView { ServerFileBrowser, SkillManager, Cockpit, + CockpitAccounts, } /// Encapsulates the active view state to enforce that all mutations go through @@ -249,6 +257,7 @@ pub struct LeftPanelView { server_file_browser_view: ViewHandle, skill_manager_view: ViewHandle, cockpit_view: ViewHandle, + cockpit_accounts_view: ViewHandle, active_view: active_view_state::ActiveViewState, available_views: Vec, toolbelt_buttons: Vec, @@ -289,6 +298,13 @@ fn secondary_return_target( .then_some(ToolPanelView::Cockpit) } +/// The Sessions toolbelt entry carries the amber waiting mark while another +/// view hides the tree, so switching to the accounts never hides that an agent +/// needs the user (#504). The tree's own header shows the count when it is open. +fn sessions_entry_shows_attention(active_view: ToolPanelView, needs_me: usize) -> bool { + needs_me > 0 && active_view != ToolPanelView::Cockpit +} + fn view_remains_available(active_view: ToolPanelView, available_views: &[ToolPanelView]) -> bool { available_views .iter() @@ -340,11 +356,19 @@ impl LeftPanelView { let server_file_browser_view = ctx.add_typed_action_view(ServerFileBrowserView::new); let skill_manager_view = ctx.add_typed_action_view(SkillManagerPanel::new); let cockpit_view = ctx.add_typed_action_view(CockpitPanel::new); - ctx.subscribe_to_view(&cockpit_view, |_, _, event, ctx| match event { - crate::cockpit::panel::CockpitPanelEvent::OpenCockpitPane(account_key) => { + let cockpit_accounts_view = ctx.add_typed_action_view(CockpitAccountsPanel::new); + ctx.subscribe_to_view(&cockpit_accounts_view, |_, _, event, ctx| match event { + CockpitAccountsPanelEvent::OpenCockpitPane(account_key) => { ctx.emit(LeftPanelEvent::OpenCockpitPane(account_key.clone())); } }); + // The Sessions toolbelt entry mirrors waiting attention while another + // view is active, so the toolbelt re-renders with the live inventory. + ctx.subscribe_to_model(&CockpitModel::handle(ctx), |_, _, event, ctx| { + if matches!(event, CockpitEvent::Updated) { + ctx.notify(); + } + }); ctx.subscribe_to_view(&ssh_manager_view, |_me, _, event, ctx| { use crate::ssh_manager::SshManagerPanelEvent; match event { @@ -521,6 +545,7 @@ impl LeftPanelView { server_file_browser_view, skill_manager_view, cockpit_view, + cockpit_accounts_view, active_view: active_view_state::new(active_view), available_views: views, toolbelt_buttons, @@ -697,6 +722,18 @@ impl LeftPanelView { tooltip_keybinding_names, } } + ToolPanelView::CockpitAccounts => { + let tooltip_keybinding_names = Vec::new(); + ToolbeltButtonConfig { + icon: Icon::Users, + active_icon: None, + tooltip_text: crate::t!("workspace-left-panel-cockpit-accounts"), + action: LeftPanelAction::CockpitAccounts, + render_with_active_state: false, + tooltip_keybinding: None, + tooltip_keybinding_names, + } + } } } @@ -978,6 +1015,9 @@ impl LeftPanelView { ToolPanelView::Cockpit => { ctx.focus(&self.cockpit_view); } + ToolPanelView::CockpitAccounts => { + ctx.focus(&self.cockpit_accounts_view); + } } } @@ -1156,6 +1196,9 @@ impl LeftPanelView { self.active_view.get() == ToolPanelView::SkillManager } LeftPanelAction::Cockpit => self.active_view.get() == ToolPanelView::Cockpit, + LeftPanelAction::CockpitAccounts => { + self.active_view.get() == ToolPanelView::CockpitAccounts + } LeftPanelAction::ReturnFromSecondaryView => false, }; } @@ -1226,6 +1269,20 @@ impl LeftPanelView { .with_cursor(Cursor::PointingHand) .finish() } + + /// A small static amber dot on a toolbelt button's corner: the theme's one + /// attention role, no text and no change to the button's geometry. + fn with_attention_mark(button: Box, appearance: &Appearance) -> Box { + let diameter = 6.; + let styles = UiComponentStyles { + width: Some(diameter), + height: Some(diameter), + border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), + background: Some(ElementFill::Solid(attention_coloru(appearance))), + ..Default::default() + }; + RedNotificationDot::render_with_offset(button, &styles, (-diameter / 2., diameter / 2.)) + } } impl LeftPanelView { @@ -1310,6 +1367,9 @@ impl LeftPanelView { LeftPanelAction::Cockpit => { active_view_state::set(self, ToolPanelView::Cockpit, ctx); } + LeftPanelAction::CockpitAccounts => { + active_view_state::set(self, ToolPanelView::CockpitAccounts, ctx); + } LeftPanelAction::ReturnFromSecondaryView => { if let Some(return_target) = secondary_return_target(self.active_view.get(), &self.available_views) @@ -1421,6 +1481,7 @@ impl View for LeftPanelView { ToolPanelView::ServerFileBrowser => ctx.focus(&self.server_file_browser_view), ToolPanelView::SkillManager => ctx.focus(&self.skill_manager_view), ToolPanelView::Cockpit => ctx.focus(&self.cockpit_view), + ToolPanelView::CockpitAccounts => ctx.focus(&self.cockpit_accounts_view), } } } @@ -1439,7 +1500,12 @@ impl View for LeftPanelView { self.mouse_state_handles.server_file_browser_button.clone(), self.mouse_state_handles.skill_manager_button.clone(), self.mouse_state_handles.cockpit_button.clone(), + self.mouse_state_handles.cockpit_accounts_button.clone(), ]; + let sessions_attention = sessions_entry_shows_attention( + self.active_view.get(), + CockpitModel::as_ref(app).inventory().needs_me, + ); // If there is only one button in the toolbelt row, // there is no need to show it as it's a bit redundant. @@ -1450,7 +1516,15 @@ impl View for LeftPanelView { .with_spacing(4.0) .with_children(self.toolbelt_buttons.iter().zip(&mouse_state_handles).map( |(button_config, mouse_state)| { - Self::render_button(button_config, mouse_state.clone(), appearance) + let button = + Self::render_button(button_config, mouse_state.clone(), appearance); + if sessions_attention + && matches!(button_config.action, LeftPanelAction::Cockpit) + { + Self::with_attention_mark(button, appearance) + } else { + button + } }, )) .with_main_axis_size(MainAxisSize::Min) @@ -1529,6 +1603,14 @@ impl View for LeftPanelView { .finish(), ) .finish(), + ToolPanelView::CockpitAccounts => Shrinkable::new( + 1.0, + Container::new(ChildView::new(&self.cockpit_accounts_view).finish()) + .with_padding_left(2.) + .with_padding_right(2.) + .finish(), + ) + .finish(), }; let panel_content = Container::new({ diff --git a/app/src/workspace/view/left_panel_tests.rs b/app/src/workspace/view/left_panel_tests.rs index ea8bc4f82ac..5b7cc487f80 100644 --- a/app/src/workspace/view/left_panel_tests.rs +++ b/app/src/workspace/view/left_panel_tests.rs @@ -1,4 +1,6 @@ -use super::{secondary_return_target, view_remains_available, ToolPanelView}; +use super::{ + secondary_return_target, sessions_entry_shows_attention, view_remains_available, ToolPanelView, +}; #[test] fn ssh_manager_drill_in_returns_to_cockpit() { @@ -56,3 +58,38 @@ fn primary_panel_never_exposes_secondary_back_target() { None ); } + +#[test] +fn waiting_signal_visible_on_sessions_entry_while_accounts_active() { + // #504: with the accounts view open, a waiting agent must stay visible on + // the Sessions entry; inside the tree its own header carries the count. + assert!(sessions_entry_shows_attention( + ToolPanelView::CockpitAccounts, + 1 + )); + assert!(sessions_entry_shows_attention(ToolPanelView::SshManager, 2)); + assert!(!sessions_entry_shows_attention(ToolPanelView::Cockpit, 3)); + assert!(!sessions_entry_shows_attention( + ToolPanelView::CockpitAccounts, + 0 + )); +} + +#[test] +fn accounts_view_is_a_primary_view_without_back_target() { + assert_eq!( + secondary_return_target( + ToolPanelView::CockpitAccounts, + &[ + ToolPanelView::Cockpit, + ToolPanelView::CockpitAccounts, + ToolPanelView::SshManager, + ], + ), + None + ); + assert!(view_remains_available( + ToolPanelView::CockpitAccounts, + &[ToolPanelView::Cockpit, ToolPanelView::CockpitAccounts], + )); +} diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index 4de07a536f0..bdc7a04b57d 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -1904,7 +1904,11 @@ fn split_picker_distinguishes_unreadable_registry_from_empty_registry() { fn primary_sidebar_keeps_cockpit_and_connections_separate() { assert_eq!( super::primary_host_navigation_views(true), - vec![ToolPanelView::Cockpit, ToolPanelView::SshManager] + vec![ + ToolPanelView::Cockpit, + ToolPanelView::CockpitAccounts, + ToolPanelView::SshManager, + ] ); } @@ -1913,7 +1917,27 @@ fn connections_remains_available_when_cockpit_is_enabled() { let views = super::primary_host_navigation_views(true); assert!(views.contains(&ToolPanelView::SshManager)); assert!(views.contains(&ToolPanelView::Cockpit)); - assert_eq!(views.len(), 2); + assert_eq!(views.len(), 3); +} + +#[test] +fn toolbelt_has_separate_sessions_and_accounts_views() { + // #504: the session tree and the account cards are two entries side by + // side, so a long inventory never pushes the accounts out of reach. + let views = super::primary_host_navigation_views(true); + let sessions = views + .iter() + .position(|view| *view == ToolPanelView::Cockpit); + let accounts = views + .iter() + .position(|view| *view == ToolPanelView::CockpitAccounts); + assert_eq!(sessions, Some(0)); + assert_eq!(accounts, Some(1)); + // With the Cockpit off, neither view is offered; Connections stays. + assert_eq!( + super::primary_host_navigation_views(false), + vec![ToolPanelView::SshManager] + ); } #[test] diff --git a/docs/ui/README.md b/docs/ui/README.md index 35cca1d8245..75f097e45b8 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -67,8 +67,10 @@ bereits vorhandene Implementierung. Sie erklärt diese **nicht** nachträglich f Der ursprüngliche Stand von PR #465 enthielt noch den redundanten Link `cockpit-shell-sessions-connections`. Die Korrektur entfernt ihn aus dem Produktcode; Verbindungen bleiben über die vorhandene Sidebar-Navigation erreichbar. Sessionbaum und -Konten behalten ihre beiden Bereiche und scrollen unabhängig, damit ein großes Inventar -die Kontenübersicht nicht aus dem sichtbaren Bereich verdrängt. +Konten sind seit #504 zwei eigene Toolbelt-Einträge („KI-Sessions“, „KI-Konten“) mit voller +Höhe und eigenem Scrollbereich. Der Mockup zeigt beide Karten noch in einer Ansicht; für die +Sidebar gilt an dieser Stelle die Regel aus `#spec` und PRODUCT, nicht die Darstellung des +Mockups. Der Referenz-Commit `bea818ce95f71fc103eebe2a7a5c687d9a55c361` bleibt der Ausgangspunkt. Die Entfernung einzelner Abweichungen ist keine native Gesamtabnahme. Den aktuellen diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index b72076f0ff4..ef2477881bf 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -37,7 +37,7 @@

UI-Spezifikation — Cockpit, Verbindungen und Mehrhost-Panes (#160, #459–
  • Einheitliche Typografie und Einrückung, durchgehende optisch abgesetzte Sidebar, keine dekorativen Farbränder.
  • 5-h- und Wochenverbrauch stehen untereinander; Konto-Klick fügt Details hinzu oder fokussiert sie.
  • -
  • Sessionbaum und Konten bleiben zwei unabhängig scrollbare Bereiche derselben Sidebar; viele Sessions verdrängen die Kontenüberschrift nicht aus der sichtbaren Fläche.
  • +
  • Sessionbaum („KI-Sessions“) und Konten („KI-Konten“) sind zwei eigene, beschriftete Einträge im vorhandenen Toolbelt, jeder mit voller Sidebarhöhe und eigenem Scrollbereich (#504). Ist eine andere Ansicht aktiv und wartet ein Agent, trägt der Sessions-Eintrag die amberne Wartemarke.
  • Keine zusätzlichen Navigationslinks, kein unbezeichneter Konto-Pfeil, kein redundantes Pane-Schließen-Menü.
  • Theme-Rollen sind verbindlich; konkrete Browser-Demofarben und Beispieldaten sind nicht normativ.
@@ -47,7 +47,7 @@

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 C3 Es entsteht keine neue vertikale Symbolleiste. Die vorhandene Sidebar-Navigation bleibt unverändert.
  • +
  • #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.
diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 189f536b78c..9e1d0a69497 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -32,7 +32,8 @@ The previous static illustrations are superseded, not alternative approved layou ## Non-goals -- No new app-wide icon rail or navigation system. +- No new app-wide icon rail or navigation system. Adding the „KI-Konten“ entry to the existing + horizontal toolbelt (#504) is not a new navigation system. - No provider level between host and project. - No second connection registry or account-override store. - No runtime dependency on either reference repository. @@ -336,8 +337,12 @@ The previous static illustrations are superseded, not alternative approved layou roles only; no hard-coded colors or decorative pane borders are introduced. Both usage windows are stacked across the account-card width, and large account panes preserve existing session actions and cost/token provenance without repeating provider identity. - The live tree and account area scroll independently inside the existing Cockpit sidebar; - large session inventories must not push the account section out of view. + The live tree („KI-Sessions“) and the account cards („KI-Konten“) are two separate, + labelled entries of the existing horizontal sidebar toolbelt, each with the full sidebar + height and its own scroll state (#504). A session inventory of any length therefore never + pushes the accounts out of reach. While another view is active, the Sessions entry carries + the amber waiting mark when an agent needs the user. This supersedes the earlier + three-fifths height cap inside one Cockpit view. ## Verbindliche Bedienungs- und Refresh-Korrekturen diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index a5ebedcc368..f482be24abf 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -452,10 +452,14 @@ Sidebar account cards stack the five-hour and weekly meters so each meter receiv available text width. Unknown and estimated values retain their semantic state and never render as measured zero. The Cockpit and Connections panels share existing layout constants/components for section headers, hierarchy indentation, row heights, flexible identity, and fixed action slots. -The two Cockpit sections retain independent scroll state and bounded flexible height so a large -session tree cannot displace the account section. The session area receives at most three fifths -of available height; unused account space does not expand this cap. This preserves the existing -navigation. +The session tree (`ToolPanelView::Cockpit`, `CockpitPanel`) and the account cards +(`ToolPanelView::CockpitAccounts`, `CockpitAccountsPanel`) are two entries of the existing +left-panel toolbelt (#504). Each view owns the full sidebar height and one scroll state; both read +the same `CockpitModel`. A persisted `LeftPanelDisplayedTab::Cockpit` restores the tree, as before. +While another view is active and the fleet has waiting agents, the toolbelt draws a static amber +mark on the Sessions entry (`sessions_entry_shows_attention`). The `w`-jump binding stays scoped to +both sidebar views and the roomy pane. This replaces the earlier three-fifths height cap inside one +Cockpit view. Native colors, borders, selection, hover, focus, status, and progress resolve exclusively through `appearance.theme()` and existing component themes; the HTML artifact defines role relationships, not literal color values. No provider-colored border or other decorative pane outline is added. From 2a0569b699be1e838a6f6c2039ad1d5244b4ad32 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:17:27 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix(cockpit):=20Session-Baum=20nach=20Darst?= =?UTF-8?q?ellungsregel=20B+=20=C3=BCbersichtlich=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Baum zeigt dasselbe Modell (Host → Projekt → PTY-Session → Agent) ohne leere Ebenen und doppelte Labels: - Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. - Eine PTY-Session mit einem Agenten ist eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Nur Mehr-Agenten-PTYs behalten Agentzeilen. - Ein langer gemeinsamer Präfix gleichrangiger Titel wird gekürzt und gedimmt; der unterscheidende Rest bleibt sichtbar, der volle Titel steht im Tooltip. - Feste Chevron- und Symbolspalten, gedämpfte Titel für ruhende Sessions, stabile Akzent-Hinterlegung für die Session der fokussierten Pane und Abstände statt Linien. - Baumzeilen öffnen beim Hover keine Aufgabenvorschau mehr (#spec A4); die Vorschau bleibt in der Konto-Pane. Die Regel steckt in der reinen Projektion project_tree_rows und ist ohne Rendering getestet. PRODUCT, TECH, HTML-Spec und die neue Referenz docs/ui/cockpit-sessions-tree.html beschreiben sie. Refs #505 --- app/i18n/de/warp.ftl | 1 + app/i18n/en/warp.ftl | 1 + app/src/cockpit/panel.rs | 925 ++++++++++++++++------- app/src/cockpit/panel_tests.rs | 203 +++++ app/src/cockpit/style.rs | 35 + docs/ui/README.md | 1 + docs/ui/cockpit-sessions-tree.html | 129 ++++ docs/ui/cockpit-sidebar-connections.html | 3 + specs/GH-160/PRODUCT.md | 12 + specs/GH-160/TECH.md | 9 + 10 files changed, 1053 insertions(+), 266 deletions(-) create mode 100644 docs/ui/cockpit-sessions-tree.html diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index 6a11c8eb207..a47bd9a1f49 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -48,6 +48,7 @@ cockpit-zone-connections = Verbindungen cockpit-zone-connections-settings = SSH-Konfiguration öffnen cockpit-zone-sessions = KI-Sessions cockpit-zone-accounts = KI-Konten +cockpit-tree-session-untitled = Terminal # Session-Zeilen-Aktionen im Cockpit cockpit-session-adopt = ▸ übernehmen diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index a49e0f5a11d..030449c412b 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -4004,6 +4004,7 @@ cockpit-zone-connections = Connections cockpit-zone-connections-settings = Open SSH configuration cockpit-zone-sessions = AI sessions cockpit-zone-accounts = AI accounts +cockpit-tree-session-untitled = Terminal cockpit-conductor-empty = No AI sessions yet cockpit-tt-favorite-add = Add to favorites cockpit-tt-favorite-remove = Remove from favorites diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index 6613101f6f6..6f223db94d5 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -18,38 +18,40 @@ use warp_core::ui::color::coloru_with_opacity; use warp_core::ui::theme::Fill; use warpui::elements::{ Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, - CornerRadius, CrossAxisAlignment, Element, Fill as ElementFill, Flex, Hoverable, - MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, ParentAnchor, - ParentElement, ParentOffsetBounds, Point, Radius, ScrollbarWidth, Shrinkable, Stack, Text, + CornerRadius, CrossAxisAlignment, Element, Empty, Fill as ElementFill, Flex, Hoverable, + MainAxisAlignment, MainAxisSize, MouseStateHandle, Padding, ParentAnchor, ParentElement, Point, + Radius, ScrollbarWidth, Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::text_layout::ClipConfig; use warpui::windowing::{StateEvent, WindowManager}; use warpui::{ - AfterLayoutContext, AppContext, Entity, EventContext, LayoutContext, PaintContext, + AfterLayoutContext, AppContext, Entity, EntityId, EventContext, LayoutContext, PaintContext, SingletonEntity, SizeConstraint, TypedActionView, View, ViewContext, WindowId, }; use zaplex_cockpit::{ - fleet_conductor_session_count, format_relative, group_project_sessions, - host_conductor_session_count, host_ident, session_glyph, session_key, AgentInventoryStatus, - ConductorSession, FleetTree, HostAvailability, HostNode, Provider, SessionSnapshot, - SessionState, TaskState, TaskStatus, + fleet_conductor_session_count, group_project_sessions, host_conductor_session_count, + host_ident, session_glyph, session_key, AgentInventoryStatus, ConductorSession, FleetTree, + HostAvailability, HostNode, Provider, SessionSnapshot, SessionState, TaskState, TaskStatus, }; +use crate::cockpit::capabilities::terminal_for_inventory_session; use crate::cockpit::fleet_details::ManagedFleetInventory; use crate::cockpit::model::{CockpitEvent, CockpitModel}; use crate::cockpit::style::{ - attention_coloru, glyph_cell, hover_row, provider_label, status_dot_coloru, zone_card, - GLYPH_COL_WIDTH, + attention_coloru, glyph_cell, hover_row, provider_icon, provider_label, status_dot_coloru, + tree_row, zone_card, GLYPH_COL_WIDTH, }; use crate::settings::AccessibilitySettings; use crate::ui_components::icons; use crate::ui_components::window_focus_dimming::WindowFocusDimming; +use crate::workspace::ActiveSession; use crate::WorkspaceAction; pub(super) const CARD_PADDING: f32 = 8.0; pub(super) const CARD_SPACING: f32 = 4.0; const TREE_DEPTH_INDENT: f32 = 16.0; +const PROVIDER_ICON_SIZE: f32 = 11.0; const TASK_PEEK_WIDTH: f32 = 390.0; pub(super) const TASK_PEEK_DELAY: Duration = Duration::from_millis(350); const WAITING_PULSE_PERIOD: Duration = Duration::from_millis(1600); @@ -230,7 +232,6 @@ pub struct CockpitPanel { /// account + conversation identity), synced against the unified inventory. /// Clicking a row attaches the agent. conductor_row_states: HashMap, - conductor_peek_states: HashMap, /// Tooltip hover state for each agent status glyph. Kept separate from the /// clickable row and task-peek handles so only the glyph owns this tooltip. conductor_row_glyph_states: HashMap, @@ -254,6 +255,9 @@ pub struct CockpitPanel { /// Hover/click state and expansion overrides for the PTY Session level. conductor_session_states: HashMap, expanded_sessions: HashMap, + /// Tooltip hover state for a title whose shared prefix is shortened; keyed + /// by agent key (leaf rows) or session key (multi-agent rows). + conductor_title_states: HashMap, } /// Stable identity of a project group within the tree: the host identity plus @@ -332,6 +336,275 @@ pub(super) fn task_activity_label(task_state: Option<&TaskState>, relative: &str ) } +/// Below this many characters the distinguishing rest of a shortened sibling +/// title stops being recognisable, so the dimmed shared prefix gets shorter. +const MIN_DISTINCT_TAIL_CHARS: usize = 8; +/// A shared prefix shorter than this is not worth dimming. +const MIN_SHARED_PREFIX_BYTES: usize = 6; +/// The dimmed rendition of a long shared prefix keeps at most this many +/// characters before its ellipsis (`vault-curator-…`). +const MAX_DIMMED_PREFIX_CHARS: usize = 14; +/// Extra air above each further host group; groups read by spacing, not lines. +const HOST_GROUP_GAP: f32 = 6.0; +/// Extra air above each project group inside a host. +const PROJECT_GROUP_GAP: f32 = 3.0; + +/// How loudly a session title reads (#505): an idle session is quieter, so +/// running and waiting work stands out; amber remains the glyph's job alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TitleTone { + Active, + Quiet, +} + +fn title_tone(state: SessionState) -> TitleTone { + if state == SessionState::Idle { + TitleTone::Quiet + } else { + TitleTone::Active + } +} + +/// A row title, optionally split into a dimmed prefix shared with its +/// siblings and the distinguishing rest. `full` is the unshortened title. +#[derive(Clone, Debug, PartialEq, Eq)] +struct TreeLabel { + dim_prefix: Option, + text: String, + full: String, +} + +impl TreeLabel { + fn plain(text: &str) -> Self { + Self { + dim_prefix: None, + text: text.to_string(), + full: text.to_string(), + } + } +} + +/// One displayed row of a project's part of the live tree. +#[derive(Debug)] +enum TreeRowKind<'a> { + /// Collapsible project group header; `count` is what it hides. + Project { + key: String, + name: String, + expanded: bool, + count: usize, + has_waiting: bool, + }, + /// A PTY session shown as one row with its single agent on it. Without a + /// title the agent identity is the row's headline. + SessionLeaf { + label: Option, + agent: &'a SessionSnapshot, + focused: bool, + }, + /// A PTY session hosting several agents; they follow as `Agent` rows. + SessionContainer { + key: String, + label: Option, + state: SessionState, + expanded: bool, + count: usize, + needs_me: usize, + }, + /// One agent of a multi-agent session. + Agent { + agent: &'a SessionSnapshot, + focused: bool, + }, +} + +#[derive(Debug)] +struct TreeRow<'a> { + /// Indentation level below the host row. + depth: usize, + kind: TreeRowKind<'a>, +} + +fn is_title_separator(c: char) -> bool { + matches!(c, '-' | '_' | '/' | '.' | ' ') +} + +/// The title a session shows below its project (#505): its own identity, or +/// `None` when that identity would only repeat the project's name. +fn session_title(session: &SessionSnapshot, project_name: &str) -> Option { + let label = session_identity_label(session, project_name); + (!label.trim().is_empty() && label != project_name).then_some(label) +} + +/// Where a separator-bounded prefix shared by all sibling titles ends. The cut +/// backs off to an earlier separator until every title keeps at least +/// [`MIN_DISTINCT_TAIL_CHARS`] distinguishing characters; `None` when nothing +/// worth dimming is shared. +fn shared_prefix_cut(titles: &[&str]) -> Option { + let (first, rest) = titles.split_first()?; + if rest.is_empty() { + return None; + } + let mut common = first.len(); + for title in rest { + let shared = first + .bytes() + .zip(title.bytes()) + .take_while(|(a, b)| a == b) + .count(); + common = common.min(shared); + } + while !first.is_char_boundary(common) { + common -= 1; + } + let mut cut = first[..common].rfind(is_title_separator)? + 1; + while titles + .iter() + .any(|title| title[cut..].chars().count() < MIN_DISTINCT_TAIL_CHARS) + { + cut = first[..cut - 1].rfind(is_title_separator)? + 1; + } + (cut >= MIN_SHARED_PREFIX_BYTES).then_some(cut) +} + +/// The dimmed form of a shared prefix: a short one stays whole, a long one +/// keeps its leading segments up to [`MAX_DIMMED_PREFIX_CHARS`] plus `…`. +fn abbreviate_shared_prefix(prefix: &str) -> String { + if prefix.chars().count() <= MAX_DIMMED_PREFIX_CHARS { + return prefix.to_string(); + } + let head: String = prefix.chars().take(MAX_DIMMED_PREFIX_CHARS).collect(); + let kept = head + .rfind(is_title_separator) + .map_or(head.as_str(), |index| &head[..=index]); + format!("{kept}…") +} + +fn split_title(title: &str, cut: Option) -> TreeLabel { + match cut { + Some(cut) => TreeLabel { + dim_prefix: Some(abbreviate_shared_prefix(&title[..cut])), + text: title[cut..].to_string(), + full: title.to_string(), + }, + None => TreeLabel::plain(title), + } +} + +/// The B+ display rule (#505) for one project of the live tree. The data +/// model stays `Host → Project → PTY session → Agent`; only the rows differ: +/// - a project with exactly one untitled session is that session: with one +/// agent it is a single leaf row, with several the session level is skipped +/// and the agents follow the project row; +/// - a PTY session with one agent is one leaf row carrying that agent; +/// - only a PTY session with several agents keeps child agent rows; +/// - sibling titles sharing a long prefix dim it and keep the distinct rest. +fn project_tree_rows<'a>( + project_name: &str, + sessions: Vec>, + project_key: String, + project_expanded: bool, + session_expanded: impl Fn(&str) -> bool, + is_focused: impl Fn(&SessionSnapshot) -> bool, +) -> Vec> { + let titles: Vec> = sessions + .iter() + .map(|session| session_title(session.representative, project_name)) + .collect(); + let mut rows = Vec::new(); + if let ([only], [None]) = (sessions.as_slice(), titles.as_slice()) { + if let [agent] = only.agents.as_slice() { + let agent: &'a SessionSnapshot = *agent; + rows.push(TreeRow { + depth: 1, + kind: TreeRowKind::SessionLeaf { + label: Some(TreeLabel::plain(project_name)), + agent, + focused: is_focused(agent), + }, + }); + return rows; + } + rows.push(TreeRow { + depth: 1, + kind: TreeRowKind::Project { + key: project_key, + name: project_name.to_string(), + expanded: project_expanded, + count: only.agents.len(), + has_waiting: only.needs_me > 0, + }, + }); + if project_expanded { + for agent in only.agents.iter().copied() { + rows.push(TreeRow { + depth: 2, + kind: TreeRowKind::Agent { + agent, + focused: is_focused(agent), + }, + }); + } + } + return rows; + } + + rows.push(TreeRow { + depth: 1, + kind: TreeRowKind::Project { + key: project_key, + name: project_name.to_string(), + expanded: project_expanded, + count: sessions.len(), + has_waiting: sessions.iter().any(|session| session.needs_me > 0), + }, + }); + if !project_expanded { + return rows; + } + let titled: Vec<&str> = titles.iter().flatten().map(String::as_str).collect(); + let cut = shared_prefix_cut(&titled); + for (session, title) in sessions.iter().zip(&titles) { + let label = title.as_deref().map(|title| split_title(title, cut)); + if let [agent] = session.agents.as_slice() { + let agent: &'a SessionSnapshot = *agent; + rows.push(TreeRow { + depth: 2, + kind: TreeRowKind::SessionLeaf { + label, + agent, + focused: is_focused(agent), + }, + }); + continue; + } + let expanded = session_expanded(&session.key); + rows.push(TreeRow { + depth: 2, + kind: TreeRowKind::SessionContainer { + key: session.key.clone(), + label, + state: session.state, + expanded, + count: session.agents.len(), + needs_me: session.needs_me, + }, + }); + if expanded { + for agent in session.agents.iter().copied() { + rows.push(TreeRow { + depth: 3, + kind: TreeRowKind::Agent { + agent, + focused: is_focused(agent), + }, + }); + } + } + } + rows +} + impl CockpitPanel { pub fn new(ctx: &mut ViewContext) -> Self { // Re-render on theme change and whenever the snapshot updates. @@ -345,6 +618,8 @@ impl CockpitPanel { ctx.notify(); } }); + // The focused pane's session carries a stable highlight in the tree. + ctx.observe(&ActiveSession::handle(ctx), |_, _, ctx| ctx.notify()); let window_manager = WindowManager::handle(ctx); ctx.subscribe_to_model(&window_manager, |_, _, event, ctx| match event { StateEvent::ValueChanged { current, previous } => { @@ -357,7 +632,6 @@ impl CockpitPanel { window_id: ctx.window_id(), session_scroll_state: ClippedScrollStateHandle::default(), conductor_row_states: HashMap::new(), - conductor_peek_states: HashMap::new(), conductor_row_glyph_states: HashMap::new(), conductor_host_states: HashMap::new(), conductor_host_glyph_states: HashMap::new(), @@ -367,6 +641,7 @@ impl CockpitPanel { expanded_projects: HashMap::new(), conductor_session_states: HashMap::new(), expanded_sessions: HashMap::new(), + conductor_title_states: HashMap::new(), }; me.sync_conductor_states(ctx); me @@ -432,15 +707,15 @@ impl CockpitPanel { }; self.conductor_row_states .retain(|k, _| routable.contains(k)); - self.conductor_peek_states - .retain(|k, _| visible.contains(k)); self.conductor_row_glyph_states .retain(|k, _| visible.contains(k)); + self.conductor_title_states + .retain(|k, _| visible.contains(k) || session_keys.contains(k)); for key in routable { self.conductor_row_states.entry(key).or_default(); } for key in visible { - self.conductor_peek_states.entry(key.clone()).or_default(); + self.conductor_title_states.entry(key.clone()).or_default(); self.conductor_row_glyph_states.entry(key).or_default(); } // Connected host handles and explicit expansion overrides. @@ -469,7 +744,10 @@ impl CockpitPanel { self.expanded_sessions .retain(|key, _| session_keys.contains(key)); for key in session_keys { - self.conductor_session_states.entry(key).or_default(); + self.conductor_session_states + .entry(key.clone()) + .or_default(); + self.conductor_title_states.entry(key).or_default(); } } @@ -521,13 +799,6 @@ impl CockpitPanel { ) } - /// The glanceable **Conductor** for the sidebar: the unified cross-host - /// inventory as `Host → Project → Session → Agent`. Local is always present; - /// remote roots are supplied only by live daemon connections. Every level - /// starts expanded and uses explicit, stable expansion state rather than - /// scale-dependent auto-collapse. Agent leaves attach through - /// [`WorkspaceAction::AttachFleetSession`], the same route as the roomy pane - /// and the `w`-jump. /// Shared quiet zone header: uppercase label, muted total, and at most one /// trailing aggregate or affordance. The Sessions header uses that slot for /// glyph + needs-attention count, never a repeated status word. @@ -648,54 +919,267 @@ impl CockpitPanel { .finish() } - fn render_session_header( + /// An empty cell as wide as a chevron or status glyph, so every row keeps + /// its text on the shared axis of its depth (#505). + fn empty_slot() -> Box { + ConstrainedBox::new(Empty::new().finish()) + .with_width(GLYPH_COL_WIDTH) + .with_height(GLYPH_COL_WIDTH) + .finish() + } + + fn chevron_slot(expanded: bool, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let chevron = if expanded { + icons::Icon::ChevronDown + } else { + icons::Icon::ChevronRight + }; + ConstrainedBox::new( + chevron + .to_warpui_icon(theme.sub_text_color(theme.surface_1())) + .finish(), + ) + .with_width(GLYPH_COL_WIDTH) + .with_height(GLYPH_COL_WIDTH) + .finish() + } + + /// A row title. A dimmed shared prefix may shrink to an ellipsis; the + /// distinguishing rest never clips. The full title is the tooltip then. + fn tree_label( + &self, + label: &TreeLabel, + tooltip_key: &str, + size: f32, + color: ColorU, + appearance: &Appearance, + ) -> Box { + let family = appearance.ui_font_family(); + let Some(prefix) = &label.dim_prefix else { + return Self::identity_text(label.text.clone(), family, size, color); + }; + let theme = appearance.theme(); + let dim = theme + .sub_text_color(theme.surface_1()) + .with_opacity(55) + .into_solid(); + let line = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max) + .with_child( + Shrinkable::new(1.0, Self::identity_text(prefix.clone(), family, size, dim)) + .finish(), + ) + .with_child(Self::text(label.text.clone(), family, size, color)) + .finish(); + appearance.ui_builder().overlay_tool_tip_on_element( + label.full.clone(), + self.conductor_title_states + .get(tooltip_key) + .cloned() + .unwrap_or_default(), + line, + ParentAnchor::TopMiddle, + ChildAnchor::BottomMiddle, + vec2f(0.0, -4.0), + ) + } + + /// Provider icon, provider and model of one agent. As a row's headline + /// (`headline = Some(color)`) the provider takes the title tone; as the + /// second line under a session title the whole line stays muted. + fn agent_identity_line( + agent: &SessionSnapshot, + is_managed: bool, + headline: Option, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let family = appearance.ui_font_family(); + let footnote = appearance.ui_font_footnote(); + let muted = theme.sub_text_color(theme.surface_1()).into_solid(); + let presentation = agent_leaf_presentation(agent.provider, &agent.model); + let icon = ConstrainedBox::new( + provider_icon(agent.provider) + .to_warpui_icon(theme.sub_text_color(theme.surface_1())) + .finish(), + ) + .with_width(PROVIDER_ICON_SIZE) + .with_height(PROVIDER_ICON_SIZE) + .finish(); + let line = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(5.0) + .with_child(icon); + let line = match headline { + Some(color) => { + let line = line.with_child(Self::text( + presentation.provider.to_string(), + family, + appearance.ui_font_body(), + color, + )); + match presentation.model { + Some(model) => line.with_child( + Shrinkable::new( + 1.0, + Self::identity_text(model.to_string(), family, footnote, muted), + ) + .finish(), + ), + None => line, + } + } + None => { + let identity = match presentation.model { + Some(model) => format!("{} · {model}", presentation.provider), + None => presentation.provider.to_string(), + }; + line.with_child( + Shrinkable::new(1.0, Self::identity_text(identity, family, footnote, muted)) + .finish(), + ) + } + }; + let line = if is_managed { + line.with_child(Self::text("◆".to_string(), family, footnote, muted)) + } else { + line + }; + line.finish() + } + + /// One agent-carrying row: a PTY session with a single agent (titled or + /// not) or one agent of a multi-agent session. Clicking attaches the agent + /// through [`WorkspaceAction::AttachFleetSession`]. + #[allow(clippy::too_many_arguments)] + fn render_agent_leaf( + &self, + host: &HostNode, + agent: &SessionSnapshot, + label: Option<&TreeLabel>, + focused: bool, + managed_fleet: &ManagedFleetInventory, + animate_waiting: bool, + appearance: &Appearance, + ) -> Box { + let theme = appearance.theme(); + let body = appearance.ui_font_body(); + let host_id = host.host_id.as_deref(); + let key = session_key(host.is_local, host_id, agent); + let state = agent.presented_state(); + let tone_color = match title_tone(state) { + 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 + .matching_agent_session(host_id, agent) + .is_some(); + let text = match label { + Some(label) => Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .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, + )) + .finish(), + None => Self::agent_identity_line(agent, is_managed, Some(tone_color), appearance), + }; + let line = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(6.0) + .with_child(Self::empty_slot()) + .with_child(Self::state_glyph( + state, + true, + animate_waiting, + self.conductor_row_glyph_states + .get(&key) + .cloned() + .unwrap_or_default(), + appearance, + )) + .with_child(Shrinkable::new(1.0, text).finish()) + .finish(); + + // The whole row attaches on click — BOTH local and remote (remote + // in-place adopt is wired via `attach_fleet_session`). + let row_state = host + .is_available() + .then(|| self.conductor_row_states.get(&key).cloned()) + .flatten(); + match row_state { + Some(state) => { + let action = WorkspaceAction::AttachFleetSession { + host: host.host.clone(), + host_id: host_id.map(str::to_string), + session_id: agent.session_id.clone(), + provider: agent.provider, + config_dir: agent.config_dir.clone(), + account_email: agent.account_email.clone(), + account_id: agent.account_id.clone(), + is_local: host.is_local, + }; + Hoverable::new(state, move |mouse| { + tree_row(line, mouse.is_hovered(), focused, appearance) + }) + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) + .finish() + } + None => tree_row(line, false, focused, appearance), + } + } + + /// A PTY session that hosts several agents: chevron, aggregate glyph and + /// title; the count of hidden agents appears only while collapsed. + #[allow(clippy::too_many_arguments)] + fn render_session_container( &self, - session: &ConductorSession<'_>, - project_name: &str, + key: &str, + label: Option<&TreeLabel>, + state: SessionState, expanded: bool, + count: usize, + needs_me: usize, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let family = appearance.ui_font_family(); let body = appearance.ui_font_body(); - let main = theme.main_text_color(theme.surface_1()).into_solid(); + let tone_color = match title_tone(state) { + TitleTone::Active => theme.main_text_color(theme.surface_1()).into_solid(), + TitleTone::Quiet => theme.sub_text_color(theme.surface_1()).into_solid(), + }; let faint = theme .sub_text_color(theme.surface_1()) .with_opacity(55) .into_solid(); - let chevron = if expanded { - icons::Icon::ChevronDown - } else { - icons::Icon::ChevronRight + let title = match label { + Some(label) => self.tree_label(label, key, body, tone_color, appearance), + None => Self::identity_text( + crate::t!("cockpit-tree-session-untitled"), + family, + body, + tone_color, + ), }; let mut row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max) .with_spacing(6.0) - .with_child( - ConstrainedBox::new( - chevron - .to_warpui_icon(theme.sub_text_color(theme.surface_1())) - .finish(), - ) - .with_width(GLYPH_COL_WIDTH) - .with_height(GLYPH_COL_WIDTH) - .finish(), - ) - .with_child( - Shrinkable::new( - 1.0, - Self::identity_text( - session_identity_label(session.representative, project_name), - family, - body, - main, - ), - ) - .finish(), - ); - if let Some(count) = - container_count_presentation(expanded, session.agents.len(), session.needs_me) - { + .with_child(Self::chevron_slot(expanded, appearance)) + // No aggregate glyph: each agent row below carries its own state, + // and one fact has one mark (TECH §2); a collapsed count turns + // amber instead when it hides a waiting agent. + .with_child(Self::empty_slot()) + .with_child(Shrinkable::new(1.0, title).finish()); + if let Some(count) = container_count_presentation(expanded, count, needs_me) { let color = if count.attention { attention_coloru(appearance) } else { @@ -703,13 +1187,13 @@ impl CockpitPanel { }; row = row.with_child(Self::text(count.count.to_string(), family, body, color)); } - let row = row.with_main_axis_size(MainAxisSize::Max).finish(); + let row = row.finish(); let handle = self .conductor_session_states - .get(&session.key) + .get(key) .cloned() .unwrap_or_default(); - let key = session.key.clone(); + let key = key.to_string(); Hoverable::new(handle, move |mouse| { hover_row(row, mouse.is_hovered(), appearance) }) @@ -720,11 +1204,86 @@ impl CockpitPanel { .finish() } + fn render_tree_row( + &self, + row: &TreeRow<'_>, + host: &HostNode, + managed_fleet: &ManagedFleetInventory, + animate_waiting: bool, + appearance: &Appearance, + ) -> Box { + let element = match &row.kind { + TreeRowKind::Project { + key, + name, + expanded, + count, + has_waiting, + } => self.render_project_header(key, name, *count, *has_waiting, *expanded, appearance), + TreeRowKind::SessionLeaf { + label, + agent, + focused, + } => self.render_agent_leaf( + host, + agent, + label.as_ref(), + *focused, + managed_fleet, + animate_waiting, + appearance, + ), + TreeRowKind::SessionContainer { + key, + label, + state, + expanded, + count, + needs_me, + } => self.render_session_container( + key, + label.as_ref(), + *state, + *expanded, + *count, + *needs_me, + appearance, + ), + TreeRowKind::Agent { agent, focused } => self.render_agent_leaf( + host, + agent, + None, + *focused, + managed_fleet, + animate_waiting, + appearance, + ), + }; + let mut container = + Container::new(element).with_padding_left(TREE_DEPTH_INDENT * row.depth as f32); + if matches!(row.kind, TreeRowKind::Project { .. }) { + container = container.with_margin_top(PROJECT_GROUP_GAP); + } + container.finish() + } + + /// The glanceable **Conductor** for the sidebar: the unified cross-host + /// inventory as `Host → Project → Session → Agent`, displayed by the B+ + /// rule of #505 ([`project_tree_rows`]). Local is always present; remote + /// roots are supplied only by live daemon connections. Every level starts + /// expanded and uses explicit, stable expansion state rather than + /// scale-dependent auto-collapse. Agent rows attach through + /// [`WorkspaceAction::AttachFleetSession`], the same route as the roomy + /// pane and the `w`-jump. The session of the focused pane keeps a stable + /// highlight. + #[allow(clippy::too_many_arguments)] fn render_conductor( &self, tree: &FleetTree, managed_fleet: &ManagedFleetInventory, animate_waiting: bool, + focused_terminal: Option, + app: &AppContext, appearance: &Appearance, ) -> Option> { let family = appearance.ui_font_family(); @@ -771,83 +1330,44 @@ impl CockpitPanel { .finish(), ); - for host in &tree.hosts { + for (host_index, host) in tree.hosts.iter().enumerate() { let is_local = host.is_local; - let ident = host_ident(is_local, host.host_id.as_deref()); + let host_id = host.host_id.as_deref(); + let ident = host_ident(is_local, host_id); let host_expanded = self.expanded_hosts.get(&ident).copied().unwrap_or(true); - col = col.with_child(self.render_host_header(host, &ident, host_expanded, appearance)); + let mut host_row = + Container::new(self.render_host_header(host, &ident, host_expanded, appearance)); + if host_index > 0 { + host_row = host_row.with_margin_top(HOST_GROUP_GAP); + } + col = col.with_child(host_row.finish()); if !host_expanded { continue; } + let is_focused = |agent: &SessionSnapshot| { + focused_terminal.is_some() + && terminal_for_inventory_session(agent, is_local, host_id, app) + == focused_terminal + }; for project in &host.projects { let pkey = project_key(&ident, &project.root); - let expanded = self.expanded_projects.get(&pkey).copied().unwrap_or(true); - let sessions = group_project_sessions( - host.is_local, - host.host_id.as_deref(), - &project.sessions, + let project_expanded = self.expanded_projects.get(&pkey).copied().unwrap_or(true); + let rows = project_tree_rows( + &project.name, + group_project_sessions(is_local, host_id, &project.sessions), + pkey, + project_expanded, + |key| self.expanded_sessions.get(key).copied().unwrap_or(true), + &is_focused, ); - let has_waiting = sessions.iter().any(|session| session.needs_me > 0); - col = col.with_child( - Container::new(self.render_project_header( - &pkey, - &project.name, - sessions.len(), - has_waiting, - expanded, + for row in &rows { + col = col.with_child(self.render_tree_row( + row, + host, + managed_fleet, + animate_waiting, appearance, - )) - .with_padding_left(TREE_DEPTH_INDENT) - .finish(), - ); - if expanded { - for session in sessions { - col = col.with_child( - Container::new( - self.render_session_header( - &session, - &project.name, - self.expanded_sessions - .get(&session.key) - .copied() - .unwrap_or(true), - appearance, - ), - ) - .with_padding_left(TREE_DEPTH_INDENT * 2.0) - .finish(), - ); - if self - .expanded_sessions - .get(&session.key) - .copied() - .unwrap_or(true) - { - for agent in session.agents { - col = col.with_child( - Container::new( - self.render_conductor_row( - &host.host, - host.host_id.as_deref(), - agent, - is_local, - host.is_available(), - managed_fleet - .matching_agent_session( - host.host_id.as_deref(), - agent, - ) - .is_some(), - animate_waiting, - appearance, - ), - ) - .with_padding_left(TREE_DEPTH_INDENT * 3.0) - .finish(), - ); - } - } - } + )); } } if host.projects.is_empty() { @@ -877,142 +1397,6 @@ impl CockpitPanel { Some(col.finish()) } - /// One compact agent leaf: state glyph, provider, and optional model only. - /// The delayed fixed-size peek retains activity/task detail without adding a - /// task subrow or changing the tree's geometry. - #[allow(clippy::too_many_arguments)] - fn render_conductor_row( - &self, - host_label: &str, - host_id: Option<&str>, - session: &SessionSnapshot, - is_local: bool, - can_attach: bool, - is_managed: bool, - animate_waiting: bool, - appearance: &Appearance, - ) -> Box { - let theme = appearance.theme(); - let family = appearance.ui_font_family(); - let body = appearance.ui_font_body(); - let model_size = appearance.ui_font_footnote(); - let main = theme.main_text_color(theme.surface_1()).into_solid(); - let muted = theme.sub_text_color(theme.surface_1()).into_solid(); - let presentation = agent_leaf_presentation(session.provider, &session.model); - let key = session_key(is_local, host_id, session); - - let mut identity = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_spacing(6.0) - .with_child(Self::text( - presentation.provider.to_string(), - family, - body, - main, - )); - if let Some(model) = presentation.model { - identity = identity.with_child( - Shrinkable::new( - 1.0, - Self::identity_text(model.to_string(), family, model_size, muted), - ) - .finish(), - ); - } - - let mut glance = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(6.0) - .with_child(Self::state_glyph( - session.presented_state(), - true, - animate_waiting, - self.conductor_row_glyph_states - .get(&key) - .cloned() - .unwrap_or_default(), - appearance, - )) - .with_child(Shrinkable::new(1.0, identity.finish()).finish()); - if is_managed { - glance = glance.with_child(Self::text("◆".to_string(), family, body, muted)); - } - let glance = glance.with_main_axis_size(MainAxisSize::Max).finish(); - - // The whole glance line attaches on click — BOTH local and remote (remote - // in-place adopt is wired via `attach_fleet_session`). - let row = if can_attach { - match self.conductor_row_states.get(&key).cloned() { - Some(state) => { - let action = WorkspaceAction::AttachFleetSession { - host: host_label.to_string(), - host_id: host_id.map(str::to_string), - session_id: session.session_id.clone(), - provider: session.provider, - config_dir: session.config_dir.clone(), - account_email: session.account_email.clone(), - account_id: session.account_id.clone(), - is_local, - }; - // Same full-span hover grammar as the host rows (`hover_row`). - // The mouse state used to be discarded here — a clickable row - // that never says so (audit P0.2). - Hoverable::new(state, move |mouse| { - hover_row(glance, mouse.is_hovered(), appearance) - }) - .with_cursor(Cursor::PointingHand) - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) - .finish() - } - None => hover_row(glance, false, appearance), - } - } else { - hover_row(glance, false, appearance) - }; - - let Some(peek_state) = self.conductor_peek_states.get(&key).cloned() else { - return row; - }; - let peek_title = session_identity_label(session, ""); - let peek_account = session.account_email.as_ref().map_or_else( - || provider_label(session.provider).to_owned(), - |email| format!("{} · {email}", provider_label(session.provider)), - ); - let peek_host = host_label.to_owned(); - let peek_cwd = session.cwd.clone(); - let session_state = session.presented_state(); - let task_state = session.task_state.clone(); - let relative = format_relative(session.last_activity, chrono::Utc::now()); - let activity = task_activity_label(task_state.as_ref(), &relative); - Hoverable::new(peek_state, move |mouse| { - let mut stack = Stack::new().with_child(row); - if mouse.is_hovered() { - stack.add_positioned_overlay_child( - Self::render_task_peek( - &peek_title, - &peek_account, - &peek_host, - &peek_cwd, - session_state, - &activity, - task_state.as_ref(), - appearance, - ), - OffsetPositioning::offset_from_parent( - vec2f(8.0, 0.0), - ParentOffsetBounds::Unbounded, - ParentAnchor::TopRight, - ChildAnchor::TopLeft, - ), - ); - } - stack.finish() - }) - .with_hover_in_delay(TASK_PEEK_DELAY) - .finish() - } - #[allow(clippy::too_many_arguments)] pub(super) fn render_task_peek( title: &str, @@ -1201,6 +1585,9 @@ impl CockpitPanel { .with_height(GLYPH_COL_WIDTH) .finish(), ) + // The glyph column stays reserved, so the project name sits on + // the same text axis as the rows of its depth (#505). + .with_child(Self::empty_slot()) .with_child( Shrinkable::new( 1.0, @@ -1253,12 +1640,18 @@ impl View for CockpitPanel { let inventory = CockpitModel::as_ref(app).inventory().clone(); let managed_fleet = CockpitModel::as_ref(app).managed_fleet().clone(); + let focused_terminal = ActiveSession::as_ref(app).terminal_view_id(self.window_id); // The live object tree remains independent of account discovery: local // is always supplied by the model, while remote roots exist only for // currently open connections. One flat surface, no registry controls. - let session_content = if let Some(conductor) = - self.render_conductor(&inventory, &managed_fleet, animate_waiting, appearance) - { + let session_content = if let Some(conductor) = self.render_conductor( + &inventory, + &managed_fleet, + animate_waiting, + focused_terminal, + app, + appearance, + ) { zone_card(conductor, appearance) .with_uniform_padding(CARD_PADDING) .finish() diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index 50c14f2f9b0..4f58fca8ca6 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -289,3 +289,206 @@ fn empty_local_inventory_does_not_hide_scan_failures() { crate::t!("cockpit-host-inventory-pending"), ); } + +fn tree_agent( + session_id: &str, + cwd: &str, + branch: Option<&str>, + state: SessionState, +) -> SessionSnapshot { + SessionSnapshot { + session_id: session_id.into(), + cwd: cwd.into(), + name: String::new(), + state, + provider: Provider::Claude, + model: "claude-opus-5-5".into(), + effort: None, + ctx_tokens: 0, + project_root: "/work/proj".into(), + repo_root: "/work/proj".into(), + project_name: "proj".into(), + branch: branch.map(str::to_string), + worktree: None, + config_dir: None, + account_email: None, + account_id: None, + process_fingerprint: None, + pty_session_id: None, + pty_session_generation: None, + pty_foreground: false, + task_state: None, + last_activity: chrono::Utc::now(), + pid: 0, + awaiting_input: false, + turn_id: None, + attention: None, + } +} + +fn in_one_pty(mut agent: SessionSnapshot) -> SessionSnapshot { + agent.pty_session_id = Some("pty-1".into()); + agent.pty_session_generation = Some(1); + agent +} + +/// `(depth, kind, label, focused)` of one projected row. +fn row_summary(row: &TreeRow<'_>) -> (usize, &'static str, String, bool) { + match &row.kind { + TreeRowKind::Project { name, .. } => (row.depth, "project", name.clone(), false), + TreeRowKind::SessionLeaf { + label, + agent, + focused, + } => ( + row.depth, + "leaf", + label + .as_ref() + .map_or_else(|| format!("agent:{}", agent.session_id), |l| l.full.clone()), + *focused, + ), + TreeRowKind::SessionContainer { label, .. } => ( + row.depth, + "session", + label.as_ref().map_or_else(String::new, |l| l.full.clone()), + false, + ), + TreeRowKind::Agent { agent, focused } => { + (row.depth, "agent", agent.session_id.clone(), *focused) + } + } +} + +fn local_project_rows( + project_name: &str, + agents: &[SessionSnapshot], + session_expanded: bool, + focused_id: Option<&str>, +) -> Vec<(usize, &'static str, String, bool)> { + let rows = project_tree_rows( + project_name, + group_project_sessions(false, Some("host-a"), agents), + "host-a\u{1f}/work/proj".to_string(), + true, + |_| session_expanded, + |agent: &SessionSnapshot| Some(agent.session_id.as_str()) == focused_id, + ); + rows.iter().map(row_summary).collect() +} + +#[test] +fn single_untitled_session_merges_into_project_row() { + let agents = [tree_agent("a", "/work/proj", None, SessionState::Active)]; + assert_eq!( + local_project_rows("proj", &agents, true, None), + vec![(1, "leaf", "proj".to_string(), false)], + "one untitled session with one agent is a single project row" + ); +} + +#[test] +fn tree_never_repeats_parent_label_in_child_row() { + let agents = [ + tree_agent("a", "/work/proj", None, SessionState::Active), + tree_agent("b", "/work/proj", None, SessionState::Idle), + ]; + let rows = local_project_rows("proj", &agents, true, None); + assert_eq!(rows[0], (1, "project", "proj".to_string(), false)); + for (_, kind, label, _) in &rows[1..] { + assert_eq!(*kind, "leaf"); + assert_ne!(label, "proj", "a child row never repeats its project label"); + } + assert_eq!(rows.len(), 3); +} + +#[test] +fn multiple_agents_in_one_pty_render_child_rows() { + let agents = [ + in_one_pty(tree_agent( + "a", + "/work/proj", + Some("main"), + SessionState::Active, + )), + in_one_pty(tree_agent( + "b", + "/work/proj", + Some("main"), + SessionState::Idle, + )), + ]; + let rows = local_project_rows("proj", &agents, true, None); + assert_eq!(rows[0], (1, "project", "proj".to_string(), false)); + assert_eq!(rows[1], (2, "session", "main".to_string(), false)); + assert_eq!(rows.len(), 4); + assert!(rows[2..].iter().all(|row| row.0 == 3 && row.1 == "agent")); + + let collapsed = local_project_rows("proj", &agents, false, None); + assert_eq!( + collapsed.len(), + 2, + "a collapsed multi-agent session hides its agent rows" + ); +} + +#[test] +fn single_agent_sessions_are_leaves_without_agent_rows() { + let agents = [ + tree_agent("a", "/work/proj", Some("main"), SessionState::Active), + tree_agent("b", "/work/proj", Some("feat/x"), SessionState::Idle), + ]; + let rows = local_project_rows("proj", &agents, true, None); + assert_eq!(rows.len(), 3); + assert!(rows[1..].iter().all(|row| row.0 == 2 && row.1 == "leaf")); +} + +#[test] +fn similar_session_names_show_distinguishing_suffix() { + let titles = [ + "vault-curator-inbox-2026-10-06-0300", + "vault-curator-inbox-2026-10-06-0900", + "vault-curator-inbox-2026-10-06-1500", + ]; + let cut = shared_prefix_cut(&titles); + let label = split_title(titles[0], cut); + assert_eq!(label.text, "10-06-0300"); + assert_eq!(label.dim_prefix.as_deref(), Some("vault-curator-…")); + assert_eq!(label.full, titles[0]); + for title in titles { + assert!(split_title(title, cut).text.chars().count() >= MIN_DISTINCT_TAIL_CHARS); + } + + // Short or unrelated names keep their whole title. + assert_eq!(shared_prefix_cut(&["feat/a", "feat/b"]), None); + assert_eq!(shared_prefix_cut(&["main", "main"]), None); + assert_eq!(shared_prefix_cut(&["main", "release"]), None); + assert_eq!(shared_prefix_cut(&["only-one-title-here"]), None); +} + +#[test] +fn idle_session_title_uses_muted_role() { + assert_eq!(title_tone(SessionState::Idle), TitleTone::Quiet); + assert_eq!(title_tone(SessionState::Active), TitleTone::Active); + assert_eq!(title_tone(SessionState::Monitor), TitleTone::Active); + assert_eq!(title_tone(SessionState::Waiting), TitleTone::Active); +} + +#[test] +fn focused_session_row_has_stable_highlight() { + let agents = [ + tree_agent("a", "/work/proj", Some("main"), SessionState::Active), + tree_agent("b", "/work/proj", Some("feat/x"), SessionState::Idle), + ]; + let rows = local_project_rows("proj", &agents, true, Some("b")); + let focused: Vec<_> = rows.iter().filter(|row| row.3).collect(); + assert_eq!( + focused.len(), + 1, + "exactly the focused pane's session is marked" + ); + assert_eq!(focused[0].2, "feat/x"); + assert!(local_project_rows("proj", &agents, true, None) + .iter() + .all(|row| !row.3)); +} diff --git a/app/src/cockpit/style.rs b/app/src/cockpit/style.rs index f5f71976ab1..8094ede1446 100644 --- a/app/src/cockpit/style.rs +++ b/app/src/cockpit/style.rs @@ -22,6 +22,7 @@ use pathfinder_color::ColorU; use warp_core::ui::appearance::Appearance; +use warp_core::ui::color::coloru_with_opacity; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::Fill; use warpui::elements::{ @@ -98,6 +99,40 @@ pub fn hover_row( c.finish() } +/// Theme-accent tint (percent opacity) of the focused pane's session row. +const FOCUSED_ROW_TINT: u8 = 18; +/// The same tint while the pointer rests on that row — colour only. +const FOCUSED_ROW_HOVER_TINT: u8 = 28; + +/// [`hover_row`] for a session-tree row that may be the session of the +/// focused pane (#505): that row keeps a stable theme-accent tint, so "where +/// am I" reads at a glance. Hover only deepens the tint; geometry is the same +/// as every other row. +pub fn tree_row( + line: Box, + hovered: bool, + focused: bool, + appearance: &Appearance, +) -> Box { + if !focused { + return hover_row(line, hovered, appearance); + } + let accent = appearance.theme().accent().into_solid(); + let tint = if hovered { + FOCUSED_ROW_HOVER_TINT + } else { + FOCUSED_ROW_TINT + }; + Container::new(line) + .with_padding_top(ROW_V_PADDING) + .with_padding_bottom(ROW_V_PADDING) + .with_padding_left(ROW_H_PADDING) + .with_padding_right(ROW_H_PADDING) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CONTROL_RADIUS))) + .with_background_color(coloru_with_opacity(accent, tint)) + .finish() +} + /// A flat sidebar **zone-card**: `surface_1`, a hairline border, radius 12 and /// **no** shadow (spec §2.1). Emphasis comes from content + spacing, never from /// heavy container chrome. Returns the still-open [`Container`] so the caller diff --git a/docs/ui/README.md b/docs/ui/README.md index 75f097e45b8..6bfb27cbf27 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -6,6 +6,7 @@ - [Interaktiver Mockup in voller Breite](premium-workspace.html): derselbe Entwurf für Desktop und schmale Breiten. - [PRODUCT](../../specs/GH-160/PRODUCT.md) und [TECH](../../specs/GH-160/TECH.md): Verhalten und native Umsetzung. - [#459](https://github.com/byte5ai/zaplex/issues/459): Koordination und Abnahme; [PR #465](https://github.com/byte5ai/zaplex/pull/465): bereits integrierte Implementierung. +- [Session-Baum und Sidebar-Ansichten](cockpit-sessions-tree.html): Toolbelt-Einträge „KI-Sessions“ und „KI-Konten“ (#504) und Baumdarstellung „B+“ (#505). Für diese beiden Punkte hat sie Vorrang vor der Sidebar-Darstellung im interaktiven Mockup. Die HTML-Dateien nach dem Checkout direkt im Browser öffnen. GitHub zeigt HTML als Quelltext, nicht als laufenden Mockup. Es ist kein Zaplex-Build erforderlich. Der Standalone-Export enthält diff --git a/docs/ui/cockpit-sessions-tree.html b/docs/ui/cockpit-sessions-tree.html new file mode 100644 index 00000000000..2a5856e0901 --- /dev/null +++ b/docs/ui/cockpit-sessions-tree.html @@ -0,0 +1,129 @@ + + + + +Sidebar: KI-Sessions, KI-Konten und Baumdarstellung B+ (#504, #505) + + +

Sidebar: „KI-Sessions“, „KI-Konten“ und Baumdarstellung „B+“

+

Normative Ergänzung zu cockpit-sidebar-connections.html für #504 und #505. Für diese beiden Punkte hat sie Vorrang vor der Sidebar-Darstellung im interaktiven Workspace-Mockup. Beispieldaten sind anonymisiert; Farben illustrieren Theme-Rollen und sind nicht normativ. Die Fixture rendert keine WarpUI-Elemente und ersetzt keine native Abnahme.

+ +
+

Regeln

+
    +
  • #spec V1 „KI-Sessions“ (Live-Baum) und „KI-Konten“ (Kontokarten) sind zwei eigene, beschriftete Einträge im vorhandenen horizontalen Toolbelt, direkt nebeneinander vor „Verbindungen“. Keine neue Symbolleiste, keine globalen Tabs.
  • +
  • #spec V2 Jede Ansicht nutzt die volle Sidebarhöhe mit eigenem Scrollbereich. Die Länge der Sessionliste beeinflusst die Kontenansicht nicht.
  • +
  • #spec V3 Ist eine andere Ansicht aktiv und wartet ein Agent auf den Nutzer, trägt der Eintrag „KI-Sessions“ eine statische amberne Marke, ohne Text und ohne Geometrieänderung.
  • +
  • #spec B1 Das Datenmodell bleibt Host → Projekt → PTY-Session → Agent; die folgenden Regeln betreffen nur die Darstellung.
  • +
  • #spec B2 Ein Projekt mit genau einer unbenannten Session (kein Name, Branch, Worktree oder abweichendes Verzeichnis) ist eine Zeile mit ihrem Agenten.
  • +
  • #spec B3 Eine PTY-Session mit einem Agenten ist eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen; ihre Zeile trägt kein Sammelsymbol.
  • +
  • #spec B4 Teilen gleichrangige Titel einen langen, an einem Trennzeichen endenden Präfix, wird er gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Der volle Titel steht im Tooltip.
  • +
  • #spec B5 Jede Zeile reserviert die Chevron- und Symbolspalte; Titel einer Ebene teilen eine Textachse.
  • +
  • #spec B6 Titel ruhender Sessions nutzen die gedämpfte Textrolle; laufende und wartende die primäre. Amber bleibt allein „wartet auf Dich“.
  • +
  • #spec B7 Die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung über die ganze Zeile. Hover vertieft nur die Farbe.
  • +
  • #spec B8 Gruppen trennen Abstände vor Host und Projekt, keine Linien oder Rahmen. Zähler erscheinen nur an zugeklappten Containern.
  • +
  • #spec B9 Baumzeilen öffnen beim Hover keine Vorschau. Aufgaben und Details stehen in der Konto-Pane.
  • +
+
+ +

Referenzansichten

+
+
+

„KI-Sessions“ aktiv, Darstellung B+

+ +
+ +
+

„KI-Konten“ aktiv: Wartemarke am Sessions-Eintrag (V3)

+ +
+
+ diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index ef2477881bf..8b335b4299c 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -58,6 +58,9 @@

Live-Baum

  • #spec T1 Hierarchie: Host → Projekt → PTY-Session → Agent.
  • #spec T2 Lokal ist immer sichtbar; eine Remote-Daemon-Identität nur von ihrer ersten bis zur letzten offenen Verbindung. Aktueller und historischer Daemon desselben Registry-Hosts dürfen während Recovery getrennt erscheinen.
  • #spec T3 Container starten aufgeklappt. Zähler erscheinen nur im zugeklappten Zustand.
  • +
  • #spec T5 Darstellung „B+“ (#505): Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. Eine PTY-Session mit einem Agenten ist eine Zeile mit Titel und darunter Provider-Icon, Provider und Modell. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen, ohne Sammelsymbol. Referenz: cockpit-sessions-tree.html.
  • +
  • #spec T6 Ein langer gemeinsamer Präfix gleichrangiger Titel wird gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar, der volle Titel steht im Tooltip.
  • +
  • #spec T7 Feste Chevron- und Symbolspalten: Titel einer Ebene teilen eine Textachse. Ruhende Sessions nutzen die gedämpfte Textrolle; die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • #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.
  • diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 9e1d0a69497..f7c2898d795 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -76,6 +76,18 @@ The previous static illustrations are superseded, not alternative approved layou supported agent conversation. Agent conversations without PTY metadata receive separate stable fallback session containers and are never merged by label alone. + **Display rule „B+“ (#505).** The tree shows this model without empty levels. A project with + exactly one untitled session (no name, branch, worktree, or distinct directory) is a single row + carrying its agent. A PTY session with one agent is one row: its title, then a second line with + provider icon, provider, and model. Only a PTY session with several agents keeps agent rows, + and its row carries no aggregate glyph. Sibling titles sharing a long, separator-bounded prefix + show it shortened and dimmed while at least eight distinguishing characters stay visible; the + full title is the tooltip. 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. The session of the + focused pane keeps a stable accent tint. Groups separate by spacing, not lines. Session rows + are two lines; this supersedes the earlier single-line session row. Reference: + [`docs/ui/cockpit-sessions-tree.html`](../../docs/ui/cockpit-sessions-tree.html). + The tree contains only agent sessions Zaplex can open, decided by the same reachability rule as a click: a Zaplex pane hosts the session, a remote live session has a reattachable foreground daemon PTY with a registry route, or the session is dormant and resumes safely. diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index f482be24abf..5b430191147 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -278,6 +278,15 @@ 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; +- the B+ display rule (#505) is the pure projection `project_tree_rows`: it merges a project with + exactly one untitled session into one row, renders a single-agent PTY session as one leaf whose + second line carries provider icon, provider, and model, keeps agent rows only for multi-agent + PTYs, and dims a separator-bounded prefix shared by sibling titles (`shared_prefix_cut`, at + least `MIN_DISTINCT_TAIL_CHARS` distinguishing characters; full title as tooltip); +- every row reserves the chevron and glyph columns; idle titles use the muted text role + (`title_tone`); the focused pane's session (workspace `ActiveSession::terminal_view_id` matched + through `terminal_for_inventory_session`) keeps a stable accent tint (`style::tree_row`); +- tree rows open no hover preview; the task peek remains only in the large account pane table. - no tree leaf renders state words, context percentage, cost, email, effort, or activity age; - the section header renders only an amber glyph and numeric count when attention exists. From cf630c78c92a5e2b2552429947a981249d136cd8 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:48:26 +0200 Subject: [PATCH 3/8] =?UTF-8?q?fix(cockpit):=20Pr=C3=A4fixk=C3=BCrzung=20p?= =?UTF-8?q?ro=20Titel=20und=20Fokus=20in=20zugeklappten=20Gruppen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befunde aus spec-check und unabhängigem Review: - Ein Titel kürzt den Präfix, den er mit mindestens einem gleichrangigen Titel teilt (shared_prefix_cuts). Ein andersnamiger Nachbar wie „main“ verhindert die Kürzung einer Gruppe ähnlicher Namen nicht mehr. - PRODUCT/HTML-Spec nennen ausdrücklich, dass ein Sessiontitel, der nur den Projektnamen wiederholt, als unbenannt gilt (#505 Regel 2). - Eine zugeklappte Projekt- oder Mehr-Agenten-Zeile, die die Session der fokussierten Pane verbirgt, trägt selbst die Akzent-Hinterlegung. - Zusammengelegte Projektzeilen erhalten denselben Gruppenabstand. - Baum und Toolbelt rendern nur neu, wenn sich das fokussierte Terminal dieses Fensters bzw. der Wartezustand der Flotte tatsächlich ändert. Refs #505 --- app/src/cockpit/panel.rs | 82 +++++++++++++++++++--- app/src/cockpit/panel_tests.rs | 32 ++++++++- app/src/workspace/view/left_panel.rs | 24 ++++--- app/src/workspace/view/left_panel_tests.rs | 14 ++-- docs/ui/cockpit-sessions-tree.html | 4 +- docs/ui/cockpit-sidebar-connections.html | 2 +- specs/GH-160/PRODUCT.md | 11 +-- specs/GH-160/TECH.md | 6 +- 8 files changed, 138 insertions(+), 37 deletions(-) diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index 6f223db94d5..b62ab86eec0 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -227,13 +227,16 @@ fn host_display_label(host: &HostNode, removed_label: &str, unverified_label: &s pub struct CockpitPanel { window_id: WindowId, + /// Terminal of this window's focused pane; its session gets the stable + /// highlight (#505). + focused_terminal: Option, session_scroll_state: ClippedScrollStateHandle, /// Hover/click state per Conductor session row (complete host + provider + /// account + conversation identity), synced against the unified inventory. /// Clicking a row attaches the agent. conductor_row_states: HashMap, /// Tooltip hover state for each agent status glyph. Kept separate from the - /// clickable row and task-peek handles so only the glyph owns this tooltip. + /// clickable row handle so only the glyph owns this tooltip. conductor_row_glyph_states: HashMap, /// Hover/click state per connected host root, keyed by stable host identity. conductor_host_states: HashMap, @@ -394,6 +397,8 @@ enum TreeRowKind<'a> { expanded: bool, count: usize, has_waiting: bool, + /// Collapsed and hiding the focused pane's session. + focused: bool, }, /// A PTY session shown as one row with its single agent on it. Without a /// title the agent identity is the row's headline. @@ -410,6 +415,8 @@ enum TreeRowKind<'a> { expanded: bool, count: usize, needs_me: usize, + /// Collapsed and hiding the focused pane's agent. + focused: bool, }, /// One agent of a multi-agent session. Agent { @@ -467,6 +474,25 @@ fn shared_prefix_cut(titles: &[&str]) -> Option { (cut >= MIN_SHARED_PREFIX_BYTES).then_some(cut) } +/// Per-title cut for [`split_title`]: each title dims the longest prefix it +/// shares with at least one sibling (by [`shared_prefix_cut`] on that pair), +/// so an unrelated sibling such as `main` never blocks a group of similar +/// names from being shortened. +fn shared_prefix_cuts(titles: &[&str]) -> Vec> { + titles + .iter() + .enumerate() + .map(|(index, title)| { + titles + .iter() + .enumerate() + .filter(|(other, _)| *other != index) + .filter_map(|(_, sibling)| shared_prefix_cut(&[*title, *sibling])) + .max() + }) + .collect() +} + /// The dimmed form of a shared prefix: a short one stays whole, a long one /// keeps its leading segments up to [`MAX_DIMMED_PREFIX_CHARS`] plus `…`. fn abbreviate_shared_prefix(prefix: &str) -> String { @@ -498,7 +524,8 @@ fn split_title(title: &str, cut: Option) -> TreeLabel { /// and the agents follow the project row; /// - a PTY session with one agent is one leaf row carrying that agent; /// - only a PTY session with several agents keeps child agent rows; -/// - sibling titles sharing a long prefix dim it and keep the distinct rest. +/// - a title sharing a long prefix with a sibling dims it and keeps the +/// distinct rest. fn project_tree_rows<'a>( project_name: &str, sessions: Vec>, @@ -533,6 +560,7 @@ fn project_tree_rows<'a>( expanded: project_expanded, count: only.agents.len(), has_waiting: only.needs_me > 0, + focused: !project_expanded && only.agents.iter().any(|agent| is_focused(*agent)), }, }); if project_expanded { @@ -557,15 +585,22 @@ fn project_tree_rows<'a>( expanded: project_expanded, count: sessions.len(), has_waiting: sessions.iter().any(|session| session.needs_me > 0), + focused: !project_expanded + && sessions + .iter() + .flat_map(|session| session.agents.iter()) + .any(|agent| is_focused(*agent)), }, }); if !project_expanded { return rows; } let titled: Vec<&str> = titles.iter().flatten().map(String::as_str).collect(); - let cut = shared_prefix_cut(&titled); + let mut cuts = shared_prefix_cuts(&titled).into_iter(); for (session, title) in sessions.iter().zip(&titles) { - let label = title.as_deref().map(|title| split_title(title, cut)); + let label = title + .as_deref() + .map(|title| split_title(title, cuts.next().flatten())); if let [agent] = session.agents.as_slice() { let agent: &'a SessionSnapshot = *agent; rows.push(TreeRow { @@ -588,6 +623,7 @@ fn project_tree_rows<'a>( expanded, count: session.agents.len(), needs_me: session.needs_me, + focused: !expanded && session.agents.iter().any(|agent| is_focused(*agent)), }, }); if expanded { @@ -619,7 +655,15 @@ impl CockpitPanel { } }); // The focused pane's session carries a stable highlight in the tree. - ctx.observe(&ActiveSession::handle(ctx), |_, _, ctx| ctx.notify()); + // `ActiveSession` also notifies on directory changes in any window, so + // only a change of this window's focused terminal re-renders. + ctx.observe(&ActiveSession::handle(ctx), |me, active_session, ctx| { + let focused = active_session.as_ref(ctx).terminal_view_id(me.window_id); + if focused != me.focused_terminal { + me.focused_terminal = focused; + ctx.notify(); + } + }); let window_manager = WindowManager::handle(ctx); ctx.subscribe_to_model(&window_manager, |_, _, event, ctx| match event { StateEvent::ValueChanged { current, previous } => { @@ -630,6 +674,7 @@ impl CockpitPanel { }); let mut me = Self { window_id: ctx.window_id(), + focused_terminal: ActiveSession::as_ref(ctx).terminal_view_id(ctx.window_id()), session_scroll_state: ClippedScrollStateHandle::default(), conductor_row_states: HashMap::new(), conductor_row_glyph_states: HashMap::new(), @@ -1147,6 +1192,7 @@ impl CockpitPanel { expanded: bool, count: usize, needs_me: usize, + focused: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); @@ -1195,7 +1241,7 @@ impl CockpitPanel { .unwrap_or_default(); let key = key.to_string(); Hoverable::new(handle, move |mouse| { - hover_row(row, mouse.is_hovered(), appearance) + tree_row(row, mouse.is_hovered(), focused, appearance) }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { @@ -1219,7 +1265,16 @@ impl CockpitPanel { expanded, count, has_waiting, - } => self.render_project_header(key, name, *count, *has_waiting, *expanded, appearance), + focused, + } => self.render_project_header( + key, + name, + *count, + *has_waiting, + *expanded, + *focused, + appearance, + ), TreeRowKind::SessionLeaf { label, agent, @@ -1240,6 +1295,7 @@ impl CockpitPanel { expanded, count, needs_me, + focused, } => self.render_session_container( key, label.as_ref(), @@ -1247,6 +1303,7 @@ impl CockpitPanel { *expanded, *count, *needs_me, + *focused, appearance, ), TreeRowKind::Agent { agent, focused } => self.render_agent_leaf( @@ -1261,7 +1318,8 @@ impl CockpitPanel { }; let mut container = Container::new(element).with_padding_left(TREE_DEPTH_INDENT * row.depth as f32); - if matches!(row.kind, TreeRowKind::Project { .. }) { + // Every project group gets the same air, merged single-row ones too. + if row.depth == 1 { container = container.with_margin_top(PROJECT_GROUP_GAP); } container.finish() @@ -1539,6 +1597,7 @@ impl CockpitPanel { /// with no status dot. The count is absent while expanded and appears only /// when collapsed; it turns amber when it hides waiting attention. Clicking /// anywhere folds/unfolds. + #[allow(clippy::too_many_arguments)] fn render_project_header( &self, pkey: &str, @@ -1546,6 +1605,7 @@ impl CockpitPanel { count: usize, has_waiting: bool, expanded: bool, + focused: bool, appearance: &Appearance, ) -> Box { let family = appearance.ui_font_family(); @@ -1610,9 +1670,10 @@ impl CockpitPanel { .finish(), ); } - hover_row( + tree_row( row.with_main_axis_size(MainAxisSize::Max).finish(), mouse.is_hovered(), + focused, appearance, ) }) @@ -1640,7 +1701,6 @@ impl View for CockpitPanel { let inventory = CockpitModel::as_ref(app).inventory().clone(); let managed_fleet = CockpitModel::as_ref(app).managed_fleet().clone(); - let focused_terminal = ActiveSession::as_ref(app).terminal_view_id(self.window_id); // The live object tree remains independent of account discovery: local // is always supplied by the model, while remote roots exist only for // currently open connections. One flat surface, no registry controls. @@ -1648,7 +1708,7 @@ impl View for CockpitPanel { &inventory, &managed_fleet, animate_waiting, - focused_terminal, + self.focused_terminal, app, appearance, ) { diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index 4f58fca8ca6..84cb8711b60 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -335,7 +335,9 @@ fn in_one_pty(mut agent: SessionSnapshot) -> SessionSnapshot { /// `(depth, kind, label, focused)` of one projected row. fn row_summary(row: &TreeRow<'_>) -> (usize, &'static str, String, bool) { match &row.kind { - TreeRowKind::Project { name, .. } => (row.depth, "project", name.clone(), false), + TreeRowKind::Project { name, focused, .. } => { + (row.depth, "project", name.clone(), *focused) + } TreeRowKind::SessionLeaf { label, agent, @@ -348,11 +350,11 @@ fn row_summary(row: &TreeRow<'_>) -> (usize, &'static str, String, bool) { .map_or_else(|| format!("agent:{}", agent.session_id), |l| l.full.clone()), *focused, ), - TreeRowKind::SessionContainer { label, .. } => ( + TreeRowKind::SessionContainer { label, focused, .. } => ( row.depth, "session", label.as_ref().map_or_else(String::new, |l| l.full.clone()), - false, + *focused, ), TreeRowKind::Agent { agent, focused } => { (row.depth, "agent", agent.session_id.clone(), *focused) @@ -430,6 +432,14 @@ fn multiple_agents_in_one_pty_render_child_rows() { 2, "a collapsed multi-agent session hides its agent rows" ); + + // A collapsed session hiding the focused agent carries the highlight + // itself; expanded, only the agent row does. + let collapsed_focused = local_project_rows("proj", &agents, false, Some("b")); + assert!(collapsed_focused[1].3); + let expanded_focused = local_project_rows("proj", &agents, true, Some("b")); + assert!(!expanded_focused[1].3); + assert_eq!(expanded_focused.iter().filter(|row| row.3).count(), 1); } #[test] @@ -492,3 +502,19 @@ fn focused_session_row_has_stable_highlight() { .iter() .all(|row| !row.3)); } + +#[test] +fn prefix_dimming_ignores_unrelated_siblings() { + let titles = [ + "main", + "vault-curator-inbox-2026-10-06-0300", + "vault-curator-inbox-2026-10-06-0900", + ]; + let cuts = shared_prefix_cuts(&titles); + assert_eq!( + cuts[0], None, + "a title without a similar sibling stays whole" + ); + assert_eq!(split_title(titles[1], cuts[1]).text, "10-06-0300"); + assert_eq!(split_title(titles[2], cuts[2]).text, "10-06-0900"); +} diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 6aa7f23c90a..b2026f00e26 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -258,6 +258,8 @@ pub struct LeftPanelView { skill_manager_view: ViewHandle, cockpit_view: ViewHandle, cockpit_accounts_view: ViewHandle, + /// Whether any fleet agent waits on the user (drives the toolbelt mark). + fleet_waiting: bool, active_view: active_view_state::ActiveViewState, available_views: Vec, toolbelt_buttons: Vec, @@ -301,8 +303,8 @@ fn secondary_return_target( /// The Sessions toolbelt entry carries the amber waiting mark while another /// view hides the tree, so switching to the accounts never hides that an agent /// needs the user (#504). The tree's own header shows the count when it is open. -fn sessions_entry_shows_attention(active_view: ToolPanelView, needs_me: usize) -> bool { - needs_me > 0 && active_view != ToolPanelView::Cockpit +fn sessions_entry_shows_attention(active_view: ToolPanelView, fleet_waiting: bool) -> bool { + fleet_waiting && active_view != ToolPanelView::Cockpit } fn view_remains_available(active_view: ToolPanelView, available_views: &[ToolPanelView]) -> bool { @@ -363,12 +365,17 @@ impl LeftPanelView { } }); // The Sessions toolbelt entry mirrors waiting attention while another - // view is active, so the toolbelt re-renders with the live inventory. - ctx.subscribe_to_model(&CockpitModel::handle(ctx), |_, _, event, ctx| { + // view is active; re-render only when that fact flips. + ctx.subscribe_to_model(&CockpitModel::handle(ctx), |me, model, event, ctx| { if matches!(event, CockpitEvent::Updated) { - ctx.notify(); + let waiting = model.as_ref(ctx).inventory().needs_me > 0; + if waiting != me.fleet_waiting { + me.fleet_waiting = waiting; + ctx.notify(); + } } }); + let fleet_waiting = CockpitModel::as_ref(ctx).inventory().needs_me > 0; ctx.subscribe_to_view(&ssh_manager_view, |_me, _, event, ctx| { use crate::ssh_manager::SshManagerPanelEvent; match event { @@ -546,6 +553,7 @@ impl LeftPanelView { skill_manager_view, cockpit_view, cockpit_accounts_view, + fleet_waiting, active_view: active_view_state::new(active_view), available_views: views, toolbelt_buttons, @@ -1502,10 +1510,8 @@ impl View for LeftPanelView { self.mouse_state_handles.cockpit_button.clone(), self.mouse_state_handles.cockpit_accounts_button.clone(), ]; - let sessions_attention = sessions_entry_shows_attention( - self.active_view.get(), - CockpitModel::as_ref(app).inventory().needs_me, - ); + let sessions_attention = + sessions_entry_shows_attention(self.active_view.get(), self.fleet_waiting); // If there is only one button in the toolbelt row, // there is no need to show it as it's a bit redundant. diff --git a/app/src/workspace/view/left_panel_tests.rs b/app/src/workspace/view/left_panel_tests.rs index 5b7cc487f80..312780469f4 100644 --- a/app/src/workspace/view/left_panel_tests.rs +++ b/app/src/workspace/view/left_panel_tests.rs @@ -65,13 +65,19 @@ fn waiting_signal_visible_on_sessions_entry_while_accounts_active() { // the Sessions entry; inside the tree its own header carries the count. assert!(sessions_entry_shows_attention( ToolPanelView::CockpitAccounts, - 1 + true + )); + assert!(sessions_entry_shows_attention( + ToolPanelView::SshManager, + true + )); + assert!(!sessions_entry_shows_attention( + ToolPanelView::Cockpit, + true )); - assert!(sessions_entry_shows_attention(ToolPanelView::SshManager, 2)); - assert!(!sessions_entry_shows_attention(ToolPanelView::Cockpit, 3)); assert!(!sessions_entry_shows_attention( ToolPanelView::CockpitAccounts, - 0 + false )); } diff --git a/docs/ui/cockpit-sessions-tree.html b/docs/ui/cockpit-sessions-tree.html index 2a5856e0901..d1ec2669a51 100644 --- a/docs/ui/cockpit-sessions-tree.html +++ b/docs/ui/cockpit-sessions-tree.html @@ -61,9 +61,9 @@

    Regeln

  • #spec V2 Jede Ansicht nutzt die volle Sidebarhöhe mit eigenem Scrollbereich. Die Länge der Sessionliste beeinflusst die Kontenansicht nicht.
  • #spec V3 Ist eine andere Ansicht aktiv und wartet ein Agent auf den Nutzer, trägt der Eintrag „KI-Sessions“ eine statische amberne Marke, ohne Text und ohne Geometrieänderung.
  • #spec B1 Das Datenmodell bleibt Host → Projekt → PTY-Session → Agent; die folgenden Regeln betreffen nur die Darstellung.
  • -
  • #spec B2 Ein Projekt mit genau einer unbenannten Session (kein Name, Branch, Worktree oder abweichendes Verzeichnis) ist eine Zeile mit ihrem Agenten.
  • +
  • #spec B2 Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. Unbenannt heißt: kein Name, Branch, Worktree oder abweichendes Verzeichnis, oder diese Identität wiederholt nur den Projektnamen.
  • #spec B3 Eine PTY-Session mit einem Agenten ist eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen; ihre Zeile trägt kein Sammelsymbol.
  • -
  • #spec B4 Teilen gleichrangige Titel einen langen, an einem Trennzeichen endenden Präfix, wird er gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Der volle Titel steht im Tooltip.
  • +
  • #spec B4 Teilt ein Titel mit mindestens einem gleichrangigen Titel einen langen, an einem Trennzeichen endenden Präfix, wird dieser gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Der volle Titel steht im Tooltip.
  • #spec B5 Jede Zeile reserviert die Chevron- und Symbolspalte; Titel einer Ebene teilen eine Textachse.
  • #spec B6 Titel ruhender Sessions nutzen die gedämpfte Textrolle; laufende und wartende die primäre. Amber bleibt allein „wartet auf Dich“.
  • #spec B7 Die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung über die ganze Zeile. Hover vertieft nur die Farbe.
  • diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index 8b335b4299c..ae53c5b2840 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -59,7 +59,7 @@

    Live-Baum

  • #spec T2 Lokal ist immer sichtbar; eine Remote-Daemon-Identität nur von ihrer ersten bis zur letzten offenen Verbindung. Aktueller und historischer Daemon desselben Registry-Hosts dürfen während Recovery getrennt erscheinen.
  • #spec T3 Container starten aufgeklappt. Zähler erscheinen nur im zugeklappten Zustand.
  • #spec T5 Darstellung „B+“ (#505): Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. Eine PTY-Session mit einem Agenten ist eine Zeile mit Titel und darunter Provider-Icon, Provider und Modell. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen, ohne Sammelsymbol. Referenz: cockpit-sessions-tree.html.
  • -
  • #spec T6 Ein langer gemeinsamer Präfix gleichrangiger Titel wird gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar, der volle Titel steht im Tooltip.
  • +
  • #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, der volle Titel steht im Tooltip.
  • #spec T7 Feste Chevron- und Symbolspalten: Titel einer Ebene teilen eine Textachse. Ruhende Sessions nutzen die gedämpfte Textrolle; die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • #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.
  • diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index f7c2898d795..048ef39910c 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -77,12 +77,13 @@ The previous static illustrations are superseded, not alternative approved layou fallback session containers and are never merged by label alone. **Display rule „B+“ (#505).** The tree shows this model without empty levels. A project with - exactly one untitled session (no name, branch, worktree, or distinct directory) is a single row - carrying its agent. A PTY session with one agent is one row: its title, then a second line with + exactly one untitled session is a single row carrying its agent; a session is untitled when it + has no name, branch, worktree, or distinct directory, or when that identity only repeats the + project name. A PTY session with one agent is one row: its title, then a second line with provider icon, provider, and model. Only a PTY session with several agents keeps agent rows, - and its row carries no aggregate glyph. Sibling titles sharing a long, separator-bounded prefix - show it shortened and dimmed while at least eight distinguishing characters stay visible; the - full title is the tooltip. Every row reserves the chevron and glyph columns, so titles of one + 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; the full title is the tooltip. 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. The session of the focused pane keeps a stable accent tint. Groups separate by spacing, not lines. Session rows are two lines; this supersedes the earlier single-line session row. Reference: diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index 5b430191147..ccd90ead14a 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -281,8 +281,10 @@ depend on pixels: - the B+ display rule (#505) is the pure projection `project_tree_rows`: it merges a project with exactly one untitled session into one row, renders a single-agent PTY session as one leaf whose second line carries provider icon, provider, and model, keeps agent rows only for multi-agent - PTYs, and dims a separator-bounded prefix shared by sibling titles (`shared_prefix_cut`, at - least `MIN_DISTINCT_TAIL_CHARS` distinguishing characters; full title as tooltip); + PTYs, and dims a separator-bounded prefix a title shares with at least one sibling + (`shared_prefix_cuts` over pairwise `shared_prefix_cut`, at least `MIN_DISTINCT_TAIL_CHARS` + distinguishing characters; full title as tooltip); a session title that only repeats the + project name counts as untitled; - every row reserves the chevron and glyph columns; idle titles use the muted text role (`title_tone`); the focused pane's session (workspace `ActiveSession::terminal_view_id` matched through `terminal_for_inventory_session`) keeps a stable accent tint (`style::tree_row`); From 5103beb74b7847385d7e1448b83a79f014163fbb Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:03:49 +0200 Subject: [PATCH 4/8] =?UTF-8?q?fix(cockpit):=20Lange=20Titel=20mittig=20k?= =?UTF-8?q?=C3=BCrzen,=20Einzelsessions=20zusammenlegen,=20keine=20Trennpu?= =?UTF-8?q?nkte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-Entscheidung zu #505: - Ein Projekt mit genau einer PTY-Session ist immer eine Zeile. Der Projektname führt und behält Vorrang, der eigene Titel der Session (z. B. der Branch) folgt gedimmt mit Abstand und weicht zuerst. - Lange Titel behalten ihr kurzes letztes Segment und kürzen in der Mitte (feat/checkout-re…-step-2) statt am Ende. Ist der sichtbare Titel kürzer, steht der volle im Tooltip. - Titelteile, Provider und Modell trennen Abstand und Tonwert, keine Mittelpunkte mehr. - Der Sessions-Eintrag im Toolbelt nennt bei der Wartemarke die Anzahl wartender Agenten im Tooltip (nicht nur Farbe). - Eine Session ohne eigenen Titel in einem Projekt mit mehreren Sessions zeigt ihren Agenten einzeilig als Überschrift; PRODUCT und HTML-Spec sagen das jetzt ausdrücklich. Refs #505 --- app/i18n/de/warp.ftl | 4 + app/i18n/en/warp.ftl | 4 + app/src/cockpit/panel.rs | 308 ++++++++++++++++------- app/src/cockpit/panel_tests.rs | 144 +++++++++-- app/src/workspace/view/left_panel.rs | 44 ++-- docs/ui/cockpit-sessions-tree.html | 32 +-- docs/ui/cockpit-sidebar-connections.html | 4 +- specs/GH-160/PRODUCT.md | 24 +- specs/GH-160/TECH.md | 15 +- 9 files changed, 433 insertions(+), 146 deletions(-) diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index a47bd9a1f49..d2e871e1a37 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -18,6 +18,10 @@ workspace-new-session-terminal = Terminal # ── Cockpit ────────────────────────────────────────────────────────────────── workspace-left-panel-cockpit = KI-Sessions workspace-left-panel-cockpit-accounts = KI-Konten +workspace-left-panel-cockpit-waiting = { $count -> + [one] KI-Sessions: { $count } Agent wartet auf Dich + *[other] KI-Sessions: { $count } Agents warten auf Dich +} workspace-left-panel-cockpit-empty = Keine Claude- oder Codex-Konten gefunden. cockpit-loading = Konten werden geladen… cockpit-scan-failed = Einige Konten ließen sich nicht lesen. diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 030449c412b..9d9f3e29fcb 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -2834,6 +2834,10 @@ workspace-left-panel-server-file-browser = Server files workspace-left-panel-skill-manager = Skill Manager workspace-left-panel-cockpit = AI sessions workspace-left-panel-cockpit-accounts = AI accounts +workspace-left-panel-cockpit-waiting = { $count -> + [one] AI sessions: { $count } agent needs you + *[other] AI sessions: { $count } agents need you +} cockpit-sessions-waiting-toast = Waiting on you: { $sessions } cockpit-attention-inbox-title = Open items cockpit-attention-inbox-count = { $count -> diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index b62ab86eec0..c0da3e9d09a 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -368,32 +368,113 @@ fn title_tone(state: SessionState) -> TitleTone { } } -/// A row title, optionally split into a dimmed prefix shared with its -/// siblings and the distinguishing rest. `full` is the unshortened title. +/// A title longer than this keeps a short last segment visible and shortens in +/// the middle instead of at its end (`feat/checkout-re…-step-2`). +const MIDDLE_CLIP_MIN_CHARS: usize = 20; +/// The kept last segment of a middle-shortened title has at least this many +/// characters, so it still identifies the title … +const MIN_KEPT_SEGMENT_CHARS: usize = 6; +/// … and at most this many, so the shortened head keeps some room. +const MAX_KEPT_SEGMENT_CHARS: usize = 16; +/// Flex share of a merged row's project name: it keeps two thirds of a tight +/// row, the session title beside it gives way first. +const PROJECT_TITLE_FLEX: f32 = 2.0; +/// Air between a merged row's project name and its session title; no glyph +/// separates them. +const MERGED_TITLE_GAP: f32 = 6.0; + +/// How a title part reads: in the row's title tone, or dimmed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PartTone { + Title, + Dim, +} + +/// How a title part behaves when the row is too narrow. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PartFit { + /// Never shrinks: the distinguishing end of a title. + Fixed, + /// Shrinks with an end ellipsis. + Shrinks, + /// Shrinks too, but keeps the larger share (a merged row's project name). + Holds, +} + #[derive(Clone, Debug, PartialEq, Eq)] -struct TreeLabel { - dim_prefix: Option, +struct LabelPart { text: String, + tone: PartTone, + fit: PartFit, + /// Separated from the previous part by air, not by a glyph. + gap_before: bool, +} + +/// A row title as parts that render side by side; `full` is the whole title +/// for the tooltip. +#[derive(Clone, Debug, PartialEq, Eq)] +struct TreeLabel { + parts: Vec, full: String, } impl TreeLabel { fn plain(text: &str) -> Self { Self { - dim_prefix: None, - text: text.to_string(), + parts: middle_clipped(text, PartTone::Title), full: text.to_string(), } } + + /// Whether the visible title can differ from `full`, which then becomes + /// the tooltip. + fn can_shorten(&self) -> bool { + self.parts.len() > 1 || self.full.chars().count() > MIDDLE_CLIP_MIN_CHARS + } +} + +fn label_part(text: &str, tone: PartTone, fit: PartFit) -> LabelPart { + LabelPart { + text: text.to_string(), + tone, + fit, + gap_before: false, + } +} + +/// Where the kept end of a long title starts (a separator), so the title +/// shortens in the middle; `None` keeps the plain end ellipsis. +fn kept_suffix_start(text: &str) -> Option { + if text.chars().count() <= MIDDLE_CLIP_MIN_CHARS { + return None; + } + let mut start = text.rfind(is_title_separator)?; + while text[start..].chars().count() < MIN_KEPT_SEGMENT_CHARS { + start = text[..start].rfind(is_title_separator)?; + } + (start > 0 && text[start..].chars().count() <= MAX_KEPT_SEGMENT_CHARS).then_some(start) +} + +/// One title text as parts: a long text keeps its last segment and shortens +/// before it; anything else shortens at its end. +fn middle_clipped(text: &str, tone: PartTone) -> Vec { + match kept_suffix_start(text) { + Some(start) => vec![ + label_part(&text[..start], tone, PartFit::Shrinks), + label_part(&text[start..], tone, PartFit::Fixed), + ], + None => vec![label_part(text, tone, PartFit::Shrinks)], + } } /// One displayed row of a project's part of the live tree. #[derive(Debug)] enum TreeRowKind<'a> { - /// Collapsible project group header; `count` is what it hides. + /// Collapsible project group header; `count` is what it hides. A project + /// with a single multi-agent session carries that session's title too. Project { key: String, - name: String, + label: TreeLabel, expanded: bool, count: usize, has_waiting: bool, @@ -506,26 +587,62 @@ fn abbreviate_shared_prefix(prefix: &str) -> String { format!("{kept}…") } +/// A session title under its project: a prefix shared with a sibling is +/// dimmed and may shrink; the distinguishing rest stays whole when short and +/// shortens in its middle when long. fn split_title(title: &str, cut: Option) -> TreeLabel { - match cut { - Some(cut) => TreeLabel { - dim_prefix: Some(abbreviate_shared_prefix(&title[..cut])), - text: title[cut..].to_string(), - full: title.to_string(), - }, - None => TreeLabel::plain(title), + let Some(cut) = cut else { + return TreeLabel::plain(title); + }; + let rest = &title[cut..]; + let mut parts = vec![label_part( + &abbreviate_shared_prefix(&title[..cut]), + PartTone::Dim, + PartFit::Shrinks, + )]; + if rest.chars().count() <= MAX_KEPT_SEGMENT_CHARS { + parts.push(label_part(rest, PartTone::Title, PartFit::Fixed)); + } else { + parts.extend(middle_clipped(rest, PartTone::Title)); + } + TreeLabel { + parts, + full: title.to_string(), + } +} + +/// The title of a project shown as its only session (#505): the project name +/// keeps priority, the session's own title follows dimmed after some air and +/// gives way first. +fn merged_label(project_name: &str, session_title: Option<&str>) -> TreeLabel { + let mut parts = vec![label_part(project_name, PartTone::Title, PartFit::Holds)]; + let Some(title) = session_title else { + return TreeLabel { + parts, + full: project_name.to_string(), + }; + }; + let mut title_parts = middle_clipped(title, PartTone::Dim); + if let Some(first) = title_parts.first_mut() { + first.gap_before = true; + } + parts.extend(title_parts); + TreeLabel { + parts, + full: format!("{project_name} / {title}"), } } /// The B+ display rule (#505) for one project of the live tree. The data /// model stays `Host → Project → PTY session → Agent`; only the rows differ: -/// - a project with exactly one untitled session is that session: with one -/// agent it is a single leaf row, with several the session level is skipped -/// and the agents follow the project row; -/// - a PTY session with one agent is one leaf row carrying that agent; +/// - a project with exactly one PTY session is that session: the project name +/// leads, the session's own title follows dimmed; with one agent it is a +/// single leaf row, with several the agents follow the project row; +/// - a PTY session with one agent is one leaf row carrying that agent; an +/// untitled one among several shows the agent as its headline; /// - only a PTY session with several agents keeps child agent rows; /// - a title sharing a long prefix with a sibling dims it and keeps the -/// distinct rest. +/// distinct rest; a long title keeps its last segment. fn project_tree_rows<'a>( project_name: &str, sessions: Vec>, @@ -539,13 +656,14 @@ fn project_tree_rows<'a>( .map(|session| session_title(session.representative, project_name)) .collect(); let mut rows = Vec::new(); - if let ([only], [None]) = (sessions.as_slice(), titles.as_slice()) { + if let [only] = sessions.as_slice() { + let label = merged_label(project_name, titles[0].as_deref()); if let [agent] = only.agents.as_slice() { let agent: &'a SessionSnapshot = *agent; rows.push(TreeRow { depth: 1, kind: TreeRowKind::SessionLeaf { - label: Some(TreeLabel::plain(project_name)), + label: Some(label), agent, focused: is_focused(agent), }, @@ -556,7 +674,7 @@ fn project_tree_rows<'a>( depth: 1, kind: TreeRowKind::Project { key: project_key, - name: project_name.to_string(), + label, expanded: project_expanded, count: only.agents.len(), has_waiting: only.needs_me > 0, @@ -581,7 +699,7 @@ fn project_tree_rows<'a>( depth: 1, kind: TreeRowKind::Project { key: project_key, - name: project_name.to_string(), + label: TreeLabel::plain(project_name), expanded: project_expanded, count: sessions.len(), has_waiting: sessions.iter().any(|session| session.needs_me > 0), @@ -990,34 +1108,67 @@ impl CockpitPanel { .finish() } - /// A row title. A dimmed shared prefix may shrink to an ellipsis; the - /// distinguishing rest never clips. The full title is the tooltip then. - fn tree_label( - &self, + /// The parts of a title side by side. Fixed parts never clip, shrinking + /// parts end in an ellipsis; a merged row's project name keeps the larger + /// share. Parts are separated by air only. + fn label_parts( label: &TreeLabel, - tooltip_key: &str, size: f32, color: ColorU, appearance: &Appearance, ) -> Box { let family = appearance.ui_font_family(); - let Some(prefix) = &label.dim_prefix else { - return Self::identity_text(label.text.clone(), family, size, color); - }; let theme = appearance.theme(); let dim = theme .sub_text_color(theme.surface_1()) .with_opacity(55) .into_solid(); - let line = Flex::row() + let mut line = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_child( - Shrinkable::new(1.0, Self::identity_text(prefix.clone(), family, size, dim)) - .finish(), - ) - .with_child(Self::text(label.text.clone(), family, size, color)) - .finish(); + .with_main_axis_size(MainAxisSize::Max); + for part in &label.parts { + let part_color = match part.tone { + PartTone::Title => color, + PartTone::Dim => dim, + }; + let text = match part.fit { + PartFit::Fixed => Self::text(part.text.clone(), family, size, part_color), + PartFit::Shrinks | PartFit::Holds => { + Self::identity_text(part.text.clone(), family, size, part_color) + } + }; + let text = if part.gap_before { + Container::new(text) + .with_padding_left(MERGED_TITLE_GAP) + .finish() + } else { + text + }; + line = match part.fit { + PartFit::Fixed => line.with_child(text), + PartFit::Shrinks => line.with_child(Shrinkable::new(1.0, text).finish()), + PartFit::Holds => { + line.with_child(Shrinkable::new(PROJECT_TITLE_FLEX, text).finish()) + } + }; + } + line.finish() + } + + /// A row title; when the visible title can be shorter than the full one, + /// the full title is the tooltip. + fn tree_label( + &self, + label: &TreeLabel, + tooltip_key: &str, + size: f32, + color: ColorU, + appearance: &Appearance, + ) -> Box { + let line = Self::label_parts(label, size, color, appearance); + if !label.can_shorten() { + return line; + } appearance.ui_builder().overlay_tool_tip_on_element( label.full.clone(), self.conductor_title_states @@ -1031,9 +1182,10 @@ impl CockpitPanel { ) } - /// Provider icon, provider and model of one agent. As a row's headline - /// (`headline = Some(color)`) the provider takes the title tone; as the - /// second line under a session title the whole line stays muted. + /// 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. fn agent_identity_line( agent: &SessionSnapshot, is_managed: bool, @@ -1044,6 +1196,10 @@ impl CockpitPanel { let family = appearance.ui_font_family(); let footnote = appearance.ui_font_footnote(); let muted = theme.sub_text_color(theme.surface_1()).into_solid(); + let faint = theme + .sub_text_color(theme.surface_1()) + .with_opacity(55) + .into_solid(); let presentation = agent_leaf_presentation(agent.provider, &agent.model); let icon = ConstrainedBox::new( provider_icon(agent.provider) @@ -1053,40 +1209,30 @@ impl CockpitPanel { .with_width(PROVIDER_ICON_SIZE) .with_height(PROVIDER_ICON_SIZE) .finish(); + let (provider_size, provider_color, model_color) = match headline { + Some(color) => (appearance.ui_font_body(), color, muted), + None => (footnote, muted, faint), + }; let line = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_spacing(5.0) - .with_child(icon); - let line = match headline { - Some(color) => { - let line = line.with_child(Self::text( - presentation.provider.to_string(), - family, - appearance.ui_font_body(), - color, - )); - match presentation.model { - Some(model) => line.with_child( - Shrinkable::new( - 1.0, - Self::identity_text(model.to_string(), family, footnote, muted), - ) - .finish(), - ), - None => line, - } - } - None => { - let identity = match presentation.model { - Some(model) => format!("{} · {model}", presentation.provider), - None => presentation.provider.to_string(), - }; - line.with_child( - Shrinkable::new(1.0, Self::identity_text(identity, family, footnote, muted)) - .finish(), + .with_child(icon) + .with_child(Self::text( + presentation.provider.to_string(), + family, + provider_size, + provider_color, + )); + let line = match presentation.model { + Some(model) => line.with_child( + Shrinkable::new( + 1.0, + Self::identity_text(model.to_string(), family, footnote, model_color), ) - } + .finish(), + ), + None => line, }; let line = if is_managed { line.with_child(Self::text("◆".to_string(), family, footnote, muted)) @@ -1261,14 +1407,14 @@ impl CockpitPanel { let element = match &row.kind { TreeRowKind::Project { key, - name, + label, expanded, count, has_waiting, focused, } => self.render_project_header( key, - name, + label, *count, *has_waiting, *expanded, @@ -1601,7 +1747,7 @@ impl CockpitPanel { fn render_project_header( &self, pkey: &str, - name: &str, + label: &TreeLabel, count: usize, has_waiting: bool, expanded: bool, @@ -1633,7 +1779,7 @@ impl CockpitPanel { .cloned() .unwrap_or_default(); let pkey_owned = pkey.to_string(); - let name_s = name.to_string(); + let label = label.clone(); Hoverable::new(handle, move |mouse| { let name_color = if mouse.is_hovered() { main_c } else { muted_c }; let mut row = Flex::row() @@ -1649,14 +1795,8 @@ impl CockpitPanel { // the same text axis as the rows of its depth (#505). .with_child(Self::empty_slot()) .with_child( - Shrinkable::new( - 1.0, - Text::new_inline(name_s.clone(), family, body) - .with_color(name_color) - .with_clip(ClipConfig::ellipsis()) - .finish(), - ) - .finish(), + Shrinkable::new(1.0, Self::label_parts(&label, body, name_color, appearance)) + .finish(), ); if let Some(count) = count { let count_color = if count.attention { diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index 84cb8711b60..f37814a8b20 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -335,8 +335,8 @@ fn in_one_pty(mut agent: SessionSnapshot) -> SessionSnapshot { /// `(depth, kind, label, focused)` of one projected row. fn row_summary(row: &TreeRow<'_>) -> (usize, &'static str, String, bool) { match &row.kind { - TreeRowKind::Project { name, focused, .. } => { - (row.depth, "project", name.clone(), *focused) + TreeRowKind::Project { label, focused, .. } => { + (row.depth, "project", label.full.clone(), *focused) } TreeRowKind::SessionLeaf { label, @@ -419,27 +419,122 @@ fn multiple_agents_in_one_pty_render_child_rows() { Some("main"), SessionState::Idle, )), + tree_agent("c", "/work/proj", Some("feat/x"), SessionState::Idle), ]; let rows = local_project_rows("proj", &agents, true, None); assert_eq!(rows[0], (1, "project", "proj".to_string(), false)); - assert_eq!(rows[1], (2, "session", "main".to_string(), false)); - assert_eq!(rows.len(), 4); - assert!(rows[2..].iter().all(|row| row.0 == 3 && row.1 == "agent")); + let container = rows + .iter() + .position(|row| row.1 == "session") + .expect("the multi-agent PTY keeps its own row"); + assert_eq!(rows[container], (2, "session", "main".to_string(), false)); + assert_eq!( + rows.iter() + .filter(|row| row.0 == 3 && row.1 == "agent") + .count(), + 2 + ); let collapsed = local_project_rows("proj", &agents, false, None); - assert_eq!( - collapsed.len(), - 2, + assert!( + collapsed.iter().all(|row| row.1 != "agent"), "a collapsed multi-agent session hides its agent rows" ); // A collapsed session hiding the focused agent carries the highlight // itself; expanded, only the agent row does. let collapsed_focused = local_project_rows("proj", &agents, false, Some("b")); - assert!(collapsed_focused[1].3); + assert!(collapsed_focused + .iter() + .any(|row| row.1 == "session" && row.3)); let expanded_focused = local_project_rows("proj", &agents, true, Some("b")); - assert!(!expanded_focused[1].3); - assert_eq!(expanded_focused.iter().filter(|row| row.3).count(), 1); + let marked: Vec<_> = expanded_focused.iter().filter(|row| row.3).collect(); + assert_eq!(marked.len(), 1); + assert_eq!(marked[0].1, "agent"); +} + +#[test] +fn single_multi_agent_session_merges_into_project_row() { + let agents = [ + in_one_pty(tree_agent( + "a", + "/work/proj", + Some("main"), + SessionState::Active, + )), + in_one_pty(tree_agent( + "b", + "/work/proj", + Some("main"), + SessionState::Idle, + )), + ]; + let rows = local_project_rows("proj", &agents, true, None); + assert_eq!(rows[0], (1, "project", "proj / main".to_string(), false)); + assert_eq!(rows.len(), 3); + assert!(rows[1..].iter().all(|row| row.0 == 2 && row.1 == "agent")); +} + +#[test] +fn single_titled_session_merges_with_dimmed_title() { + let agents = [tree_agent( + "a", + "/work/proj", + Some("main"), + SessionState::Active, + )]; + assert_eq!( + local_project_rows("proj", &agents, true, None), + vec![(1, "leaf", "proj / main".to_string(), false)] + ); + let label = merged_label("proj", Some("main")); + assert_eq!(label.parts.len(), 2); + assert_eq!( + ( + label.parts[0].text.as_str(), + label.parts[0].tone, + label.parts[0].fit + ), + ("proj", PartTone::Title, PartFit::Holds), + "the project name leads and keeps the larger share" + ); + assert_eq!( + ( + label.parts[1].text.as_str(), + label.parts[1].tone, + label.parts[1].gap_before + ), + ("main", PartTone::Dim, true), + "the session title follows dimmed after air, without a glyph" + ); +} + +#[test] +fn long_titles_shorten_in_the_middle_and_keep_their_end() { + let label = TreeLabel::plain("feat/checkout-redesign-step-2"); + let parts: Vec<_> = label + .parts + .iter() + .map(|part| (part.text.as_str(), part.fit)) + .collect(); + assert_eq!( + parts, + vec![ + ("feat/checkout-redesign", PartFit::Shrinks), + ("-step-2", PartFit::Fixed), + ] + ); + assert!(label.can_shorten()); + + // Short titles, and long ones without a short last segment, end-clip. + assert_eq!(TreeLabel::plain("main").parts.len(), 1); + assert!(!TreeLabel::plain("main").can_shorten()); + assert_eq!( + TreeLabel::plain("averyveryverylongbranchnamewithoutseparators") + .parts + .len(), + 1 + ); } #[test] @@ -462,11 +557,22 @@ fn similar_session_names_show_distinguishing_suffix() { ]; let cut = shared_prefix_cut(&titles); let label = split_title(titles[0], cut); - assert_eq!(label.text, "10-06-0300"); - assert_eq!(label.dim_prefix.as_deref(), Some("vault-curator-…")); + let parts: Vec<_> = label + .parts + .iter() + .map(|part| (part.text.as_str(), part.tone, part.fit)) + .collect(); + assert_eq!( + parts, + vec![ + ("vault-curator-…", PartTone::Dim, PartFit::Shrinks), + ("10-06-0300", PartTone::Title, PartFit::Fixed), + ] + ); assert_eq!(label.full, titles[0]); for title in titles { - assert!(split_title(title, cut).text.chars().count() >= MIN_DISTINCT_TAIL_CHARS); + let tail = split_title(title, cut).parts.last().unwrap().text.clone(); + assert!(tail.chars().count() >= MIN_DISTINCT_TAIL_CHARS); } // Short or unrelated names keep their whole title. @@ -515,6 +621,12 @@ fn prefix_dimming_ignores_unrelated_siblings() { cuts[0], None, "a title without a similar sibling stays whole" ); - assert_eq!(split_title(titles[1], cuts[1]).text, "10-06-0300"); - assert_eq!(split_title(titles[2], cuts[2]).text, "10-06-0900"); + assert_eq!( + split_title(titles[1], cuts[1]).parts.last().unwrap().text, + "10-06-0300" + ); + assert_eq!( + split_title(titles[2], cuts[2]).parts.last().unwrap().text, + "10-06-0900" + ); } diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index b2026f00e26..4ed51e81f20 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -258,8 +258,9 @@ pub struct LeftPanelView { skill_manager_view: ViewHandle, cockpit_view: ViewHandle, cockpit_accounts_view: ViewHandle, - /// Whether any fleet agent waits on the user (drives the toolbelt mark). - fleet_waiting: bool, + /// How many fleet agents wait on the user (drives the toolbelt mark and + /// its accessible label). + fleet_waiting: usize, active_view: active_view_state::ActiveViewState, available_views: Vec, toolbelt_buttons: Vec, @@ -368,14 +369,14 @@ impl LeftPanelView { // view is active; re-render only when that fact flips. ctx.subscribe_to_model(&CockpitModel::handle(ctx), |me, model, event, ctx| { if matches!(event, CockpitEvent::Updated) { - let waiting = model.as_ref(ctx).inventory().needs_me > 0; + let waiting = model.as_ref(ctx).inventory().needs_me; if waiting != me.fleet_waiting { me.fleet_waiting = waiting; ctx.notify(); } } }); - let fleet_waiting = CockpitModel::as_ref(ctx).inventory().needs_me > 0; + let fleet_waiting = CockpitModel::as_ref(ctx).inventory().needs_me; ctx.subscribe_to_view(&ssh_manager_view, |_me, _, event, ctx| { use crate::ssh_manager::SshManagerPanelEvent; match event { @@ -1215,6 +1216,7 @@ impl LeftPanelView { fn render_button( button_config: &ToolbeltButtonConfig, mouse_state: MouseStateHandle, + tooltip_text: String, appearance: &Appearance, ) -> Box { let action = button_config.action.clone(); @@ -1232,14 +1234,11 @@ impl LeftPanelView { let tooltip = if let Some(keybinding) = tooltip_keybinding { ui_builder - .tool_tip_with_sublabel(button_config.tooltip_text.clone(), keybinding) + .tool_tip_with_sublabel(tooltip_text, keybinding) .build() .finish() } else { - ui_builder - .tool_tip(button_config.tooltip_text.clone()) - .build() - .finish() + ui_builder.tool_tip(tooltip_text).build().finish() }; let icon = if button_config.render_with_active_state { @@ -1511,7 +1510,7 @@ impl View for LeftPanelView { self.mouse_state_handles.cockpit_accounts_button.clone(), ]; let sessions_attention = - sessions_entry_shows_attention(self.active_view.get(), self.fleet_waiting); + sessions_entry_shows_attention(self.active_view.get(), self.fleet_waiting > 0); // If there is only one button in the toolbelt row, // there is no need to show it as it's a bit redundant. @@ -1522,14 +1521,31 @@ impl View for LeftPanelView { .with_spacing(4.0) .with_children(self.toolbelt_buttons.iter().zip(&mouse_state_handles).map( |(button_config, mouse_state)| { - let button = - Self::render_button(button_config, mouse_state.clone(), appearance); if sessions_attention && matches!(button_config.action, LeftPanelAction::Cockpit) { - Self::with_attention_mark(button, appearance) + // The mark is never colour alone: the tooltip + // names how many agents wait. + let tooltip = crate::t!( + "workspace-left-panel-cockpit-waiting", + count = (self.fleet_waiting as i64) + ); + Self::with_attention_mark( + Self::render_button( + button_config, + mouse_state.clone(), + tooltip, + appearance, + ), + appearance, + ) } else { - button + Self::render_button( + button_config, + mouse_state.clone(), + button_config.tooltip_text.clone(), + appearance, + ) } }, )) diff --git a/docs/ui/cockpit-sessions-tree.html b/docs/ui/cockpit-sessions-tree.html index d1ec2669a51..d6aa7bcaa5e 100644 --- a/docs/ui/cockpit-sessions-tree.html +++ b/docs/ui/cockpit-sessions-tree.html @@ -23,6 +23,7 @@ .card { background: #222326; border: 1px solid rgba(255,255,255,.08); border-radius: 12px; padding: 10px 0 8px; } .zone { display: flex; justify-content: space-between; gap: 12px; padding: 0 14px 6px; font-size: 11px; letter-spacing: .06em; color: #8b8d92; } .zone .att { display: flex; gap: 5px; align-items: center; color: #f5a524; } + .zone span { white-space: nowrap; } .row { display: flex; align-items: flex-start; gap: 6px; margin: 0 6px; padding: 3px 8px 3px 4px; border-radius: 4px; } .row.two { padding-top: 4px; padding-bottom: 5px; } .row.focused { background: rgba(110,150,255,.16); } @@ -41,6 +42,8 @@ .t-active { font-weight: 500; color: #ececec; } .t-quiet { color: #a3a5aa; } .dim { color: #6b6d72; font-weight: 400; } + .gap { margin-left: 6px; } + .faint { color: #6b6d72; } .sub { display: flex; align-items: center; gap: 5px; font-size: 11.5px; line-height: 15px; color: #8b8d92; white-space: nowrap; overflow: hidden; } .acct { margin: 0 14px 10px; } .acct .prov { display: flex; gap: 6px; align-items: center; color: #ececec; font-size: 14px; } @@ -61,9 +64,9 @@

    Regeln

  • #spec V2 Jede Ansicht nutzt die volle Sidebarhöhe mit eigenem Scrollbereich. Die Länge der Sessionliste beeinflusst die Kontenansicht nicht.
  • #spec V3 Ist eine andere Ansicht aktiv und wartet ein Agent auf den Nutzer, trägt der Eintrag „KI-Sessions“ eine statische amberne Marke, ohne Text und ohne Geometrieänderung.
  • #spec B1 Das Datenmodell bleibt Host → Projekt → PTY-Session → Agent; die folgenden Regeln betreffen nur die Darstellung.
  • -
  • #spec B2 Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. Unbenannt heißt: kein Name, Branch, Worktree oder abweichendes Verzeichnis, oder diese Identität wiederholt nur den Projektnamen.
  • -
  • #spec B3 Eine PTY-Session mit einem Agenten ist eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen; ihre Zeile trägt kein Sammelsymbol.
  • -
  • #spec B4 Teilt ein Titel mit mindestens einem gleichrangigen Titel einen langen, an einem Trennzeichen endenden Präfix, wird dieser gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Der volle Titel steht im Tooltip.
  • +
  • #spec B2 Ein Projekt mit genau einer PTY-Session ist eine Zeile: Der Projektname führt und behält Vorrang, der eigene Titel der Session (Name, Branch, Worktree oder abweichendes Verzeichnis) folgt gedimmt mit Abstand und weicht zuerst. Ein Titel, der nur den Projektnamen wiederholt, entfällt.
  • +
  • #spec B3 Bei mehreren Sessions ist eine PTY-Session mit einem Agenten eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Ohne eigenen Titel ist der Agent die einzeilige Überschrift. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen; ihre Zeile trägt kein Sammelsymbol.
  • +
  • #spec B4 Teilt ein Titel mit mindestens einem gleichrangigen Titel einen langen, an einem Trennzeichen endenden Präfix, wird dieser 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 (feat/checkout-re…-step-2), sonst am Ende. Kann der sichtbare Titel kürzer sein, steht der volle im Tooltip. Titelteile, Provider und Modell trennen Abstand und Tonwert, nie Trennzeichen wie Mittelpunkte.
  • #spec B5 Jede Zeile reserviert die Chevron- und Symbolspalte; Titel einer Ebene teilen eine Textachse.
  • #spec B6 Titel ruhender Sessions nutzen die gedämpfte Textrolle; laufende und wartende die primäre. Amber bleibt allein „wartet auf Dich“.
  • #spec B7 Die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung über die ganze Zeile. Hover vertieft nur die Farbe.
  • @@ -75,7 +78,7 @@

    Regeln

    Referenzansichten

    -

    „KI-Sessions“ aktiv, Darstellung B+

    +

    „KI-Sessions“ aktiv, Darstellung B+: zusammengelegte Projekte (notes, docs-site, zaplex), Mittelkürzung (webshop), gemeinsamer Präfix (ops-scripts)

    diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index ae53c5b2840..45c8cdf1316 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -58,8 +58,8 @@

    Live-Baum

  • #spec T1 Hierarchie: Host → Projekt → PTY-Session → Agent.
  • #spec T2 Lokal ist immer sichtbar; eine Remote-Daemon-Identität nur von ihrer ersten bis zur letzten offenen Verbindung. Aktueller und historischer Daemon desselben Registry-Hosts dürfen während Recovery getrennt erscheinen.
  • #spec T3 Container starten aufgeklappt. Zähler erscheinen nur im zugeklappten Zustand.
  • -
  • #spec T5 Darstellung „B+“ (#505): Ein Projekt mit genau einer unbenannten Session ist eine Zeile mit ihrem Agenten. Eine PTY-Session mit einem Agenten ist eine Zeile mit Titel und darunter Provider-Icon, Provider und Modell. 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, der volle Titel steht im Tooltip.
  • +
  • #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 Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • #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.
  • diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 048ef39910c..37f080a68bc 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -77,16 +77,22 @@ The previous static illustrations are superseded, not alternative approved layou fallback session containers and are never merged by label alone. **Display rule „B+“ (#505).** The tree shows this model without empty levels. A project with - exactly one untitled session is a single row carrying its agent; a session is untitled when it - has no name, branch, worktree, or distinct directory, or when that identity only repeats the - project name. A PTY session with one agent is one row: its title, then a second line with - provider icon, provider, and model. 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; the full title is the tooltip. 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. The session of the + exactly one PTY session is a single row: the project name leads and keeps priority, the + session's own title (name, branch, worktree, or distinct directory) follows dimmed after some + air and gives way first; a title that only repeats the project name is omitted. With one agent + that row carries the agent; with several, the agents follow it. In a project with several PTY + 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 + 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. The session of the focused pane keeps a stable accent tint. Groups separate by spacing, not lines. Session rows - are two lines; this supersedes the earlier single-line session row. Reference: + with a title are two lines; this supersedes the earlier single-line session row. Reference: [`docs/ui/cockpit-sessions-tree.html`](../../docs/ui/cockpit-sessions-tree.html). The tree contains only agent sessions Zaplex can open, decided by the same reachability rule diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index ccd90ead14a..a3faab3fa88 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -278,13 +278,16 @@ 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; -- the B+ display rule (#505) is the pure projection `project_tree_rows`: it merges a project with - exactly one untitled session into one row, renders a single-agent PTY session as one leaf whose - second line carries provider icon, provider, and model, keeps agent rows only for multi-agent - PTYs, and dims a separator-bounded prefix a title shares with at least one sibling +- 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 + leaf whose second line carries provider icon, provider, and model, and an untitled one shows + the agent as its headline; agent rows remain only for multi-agent PTYs. Titles are + `TreeLabel` parts: a separator-bounded prefix shared with at least one sibling is dimmed (`shared_prefix_cuts` over pairwise `shared_prefix_cut`, at least `MIN_DISTINCT_TAIL_CHARS` - distinguishing characters; full title as tooltip); a session title that only repeats the - project name counts as untitled; + distinguishing characters), and a long title keeps its last segment fixed while the head + shrinks (`kept_suffix_start`, `MIDDLE_CLIP_MIN_CHARS`); `TreeLabel::can_shorten` attaches the + full title as tooltip. Parts, provider, and model are separated by spacing, never by glyphs; - every row reserves the chevron and glyph columns; idle titles use the muted text role (`title_tone`); the focused pane's session (workspace `ActiveSession::terminal_view_id` matched through `terminal_for_inventory_session`) keeps a stable accent tint (`style::tree_row`); From 526826666e22aecdbd08b8aea35d2580ee289d36 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:08:37 +0200 Subject: [PATCH 5/8] =?UTF-8?q?fix(cockpit):=20Verzeichnistitel=20ohne=20P?= =?UTF-8?q?rojektnamen,=20Tooltip=20f=C3=BCr=20jeden=20Titel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befunde aus spec-check (Lauf 3): - Ein Verzeichnis als Sessiontitel ist nur noch der Verzeichnisname. Bisher entstand „projekt — verzeichnis“ und in zusammengelegten Zeilen sogar „projekt projekt — verzeichnis“. - Jeder Baumtitel trägt seinen vollen Text als Tooltip, Projektzeilen eingeschlossen; auch kurze Titel können in schmaler Sidebar abgeschnitten werden. - TECH hält fest, dass verwaltete Agents (#168) im Baum weiter die kompakte ◆-Marke tragen; Details stehen in der Konto-Pane. Refs #505 --- app/src/cockpit/panel.rs | 96 +++++++++++++++++++--------------- app/src/cockpit/panel_tests.rs | 17 +++++- specs/GH-160/TECH.md | 8 +-- 3 files changed, 74 insertions(+), 47 deletions(-) diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index c0da3e9d09a..55ecef05ca6 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -258,8 +258,8 @@ pub struct CockpitPanel { /// Hover/click state and expansion overrides for the PTY Session level. conductor_session_states: HashMap, expanded_sessions: HashMap, - /// Tooltip hover state for a title whose shared prefix is shortened; keyed - /// by agent key (leaf rows) or session key (multi-agent rows). + /// Tooltip hover state for a row title; keyed by agent key (leaf rows), + /// session key (multi-agent rows), or project key (project rows). conductor_title_states: HashMap, } @@ -273,29 +273,15 @@ fn project_key(host_ident: &str, project_root: &str) -> String { format!("{host_ident}\u{1f}{project_root}") } -/// The branch-first label that identifies a session in the redesigned sidebar -/// (spec §2.2): the session's own registry name if it has one, else its git -/// branch, else its linked-worktree name, else the project + cwd basename. The -/// **model is never** the identity — several parallel Opus agents differ by -/// worktree/branch, not model. -fn session_identity_label(session: &SessionSnapshot, project_name: &str) -> String { - if !session.name.is_empty() { - return session.name.clone(); - } - if let Some(branch) = session.branch.as_deref().filter(|b| !b.is_empty()) { - return branch.to_string(); - } - if let Some(worktree) = session.worktree.as_deref().filter(|w| !w.is_empty()) { - return worktree.to_string(); - } - project_directory_label(project_name, &session.cwd) +fn directory_name(cwd: &str) -> String { + Path::new(cwd) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| cwd.to_string()) } fn project_directory_label(project_name: &str, cwd: &str) -> String { - let dir = Path::new(cwd) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| cwd.to_string()); + let dir = directory_name(cwd); if project_name == dir.as_str() { project_name.to_string() } else if !project_name.is_empty() { @@ -425,12 +411,6 @@ impl TreeLabel { full: text.to_string(), } } - - /// Whether the visible title can differ from `full`, which then becomes - /// the tooltip. - fn can_shorten(&self) -> bool { - self.parts.len() > 1 || self.full.chars().count() > MIDDLE_CLIP_MIN_CHARS - } } fn label_part(text: &str, tone: PartTone, fit: PartFit) -> LabelPart { @@ -517,11 +497,27 @@ fn is_title_separator(c: char) -> bool { matches!(c, '-' | '_' | '/' | '.' | ' ') } -/// The title a session shows below its project (#505): its own identity, or -/// `None` when that identity would only repeat the project's name. +/// The branch-first title of a session in the tree (spec §2.2, #505): its own +/// registry name, else its git branch, else its linked-worktree name, else its +/// directory where that differs from the project. The **model is never** the +/// identity — parallel agents on one model differ by worktree/branch. `None` +/// when nothing beyond the project's name identifies the session; the title +/// never repeats the project name. fn session_title(session: &SessionSnapshot, project_name: &str) -> Option { - let label = session_identity_label(session, project_name); - (!label.trim().is_empty() && label != project_name).then_some(label) + let own = [ + Some(session.name.as_str()), + session.branch.as_deref(), + session.worktree.as_deref(), + ] + .into_iter() + .flatten() + .find(|value| !value.trim().is_empty()) + .map(str::to_string); + let title = own.or_else(|| { + (project_directory_label(project_name, &session.cwd) != project_name) + .then(|| directory_name(&session.cwd)) + })?; + (title != project_name).then_some(title) } /// Where a separator-bounded prefix shared by all sibling titles ends. The cut @@ -872,8 +868,9 @@ impl CockpitPanel { .retain(|k, _| routable.contains(k)); self.conductor_row_glyph_states .retain(|k, _| visible.contains(k)); - self.conductor_title_states - .retain(|k, _| visible.contains(k) || session_keys.contains(k)); + self.conductor_title_states.retain(|k, _| { + visible.contains(k) || session_keys.contains(k) || project_keys.contains(k) + }); for key in routable { self.conductor_row_states.entry(key).or_default(); } @@ -900,7 +897,10 @@ impl CockpitPanel { self.expanded_projects .retain(|k, _| project_keys.contains(k)); for key in project_keys { - self.conductor_project_states.entry(key).or_default(); + self.conductor_project_states + .entry(key.clone()) + .or_default(); + self.conductor_title_states.entry(key).or_default(); } self.conductor_session_states .retain(|key, _| session_keys.contains(key)); @@ -1155,8 +1155,8 @@ impl CockpitPanel { line.finish() } - /// A row title; when the visible title can be shorter than the full one, - /// the full title is the tooltip. + /// A row title with the full title as tooltip: any title can end up + /// shorter than its text in a narrow sidebar. fn tree_label( &self, label: &TreeLabel, @@ -1166,9 +1166,6 @@ impl CockpitPanel { appearance: &Appearance, ) -> Box { let line = Self::label_parts(label, size, color, appearance); - if !label.can_shorten() { - return line; - } appearance.ui_builder().overlay_tool_tip_on_element( label.full.clone(), self.conductor_title_states @@ -1778,6 +1775,11 @@ impl CockpitPanel { .get(pkey) .cloned() .unwrap_or_default(); + let title_tooltip = self + .conductor_title_states + .get(pkey) + .cloned() + .unwrap_or_default(); let pkey_owned = pkey.to_string(); let label = label.clone(); Hoverable::new(handle, move |mouse| { @@ -1795,8 +1797,18 @@ impl CockpitPanel { // the same text axis as the rows of its depth (#505). .with_child(Self::empty_slot()) .with_child( - Shrinkable::new(1.0, Self::label_parts(&label, body, name_color, appearance)) - .finish(), + Shrinkable::new( + 1.0, + appearance.ui_builder().overlay_tool_tip_on_element( + label.full.clone(), + title_tooltip, + Self::label_parts(&label, body, name_color, appearance), + ParentAnchor::TopMiddle, + ChildAnchor::BottomMiddle, + vec2f(0.0, -4.0), + ), + ) + .finish(), ); if let Some(count) = count { let count_color = if count.attention { diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index f37814a8b20..e9ccc234878 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -524,11 +524,9 @@ fn long_titles_shorten_in_the_middle_and_keep_their_end() { ("-step-2", PartFit::Fixed), ] ); - assert!(label.can_shorten()); // Short titles, and long ones without a short last segment, end-clip. assert_eq!(TreeLabel::plain("main").parts.len(), 1); - assert!(!TreeLabel::plain("main").can_shorten()); assert_eq!( TreeLabel::plain("averyveryverylongbranchnamewithoutseparators") .parts @@ -630,3 +628,18 @@ fn prefix_dimming_ignores_unrelated_siblings() { "10-06-0900" ); } + +#[test] +fn directory_title_never_repeats_the_project_name() { + let agents = [tree_agent( + "a", + "/work/proj/tools", + None, + SessionState::Active, + )]; + assert_eq!( + local_project_rows("proj", &agents, true, None), + vec![(1, "leaf", "proj / tools".to_string(), false)], + "a distinct directory is the session title on its own" + ); +} diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index a3faab3fa88..3b6a063775d 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -277,7 +277,8 @@ depend on pixels: - expanded containers hide counts; collapsed containers show a count; - 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; +- 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; - 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 @@ -286,8 +287,9 @@ depend on pixels: `TreeLabel` parts: a separator-bounded prefix shared with at least one sibling is dimmed (`shared_prefix_cuts` over pairwise `shared_prefix_cut`, at least `MIN_DISTINCT_TAIL_CHARS` distinguishing characters), and a long title keeps its last segment fixed while the head - shrinks (`kept_suffix_start`, `MIDDLE_CLIP_MIN_CHARS`); `TreeLabel::can_shorten` attaches the - full title as tooltip. Parts, provider, and model are separated by spacing, never by glyphs; + shrinks (`kept_suffix_start`, `MIDDLE_CLIP_MIN_CHARS`); every row title, project rows included, + carries its full text as tooltip, because any title can clip in a narrow sidebar. A directory + title is the directory's own name, never `project — directory`. Parts, provider, and model are separated by spacing, never by glyphs; - every row reserves the chevron and glyph columns; idle titles use the muted text role (`title_tone`); the focused pane's session (workspace `ActiveSession::terminal_view_id` matched through `terminal_for_inventory_session`) keeps a stable accent tint (`style::tree_row`); From 4d48a04f49126425ce4a5fdb3e9a51b12c691b56 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:52:56 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(cockpit):=20Identische=20Titel=20nie=20?= =?UTF-8?q?k=C3=BCrzen,=20Sessionname=20wie=20Projekt=20=C3=BCberspringen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befunde aus spec-check (Lauf 5): - Endet ein Titel innerhalb des gemeinsamen Teils (auch bei identischen Titeln), bleibt nichts Unterscheidendes übrig; dann wird nicht gekürzt. - Als Sessiontitel zählt die erste Quelle (Name, Branch, Worktree), die nicht nur den Projektnamen wiederholt. Eine Session, die wie das Projekt heißt, zeigt so ihren Branch. Refs #505 --- app/src/cockpit/panel.rs | 17 +++++++++++------ app/src/cockpit/panel_tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index 55ecef05ca6..ac2d1aa1282 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -504,20 +504,20 @@ fn is_title_separator(c: char) -> bool { /// when nothing beyond the project's name identifies the session; the title /// never repeats the project name. fn session_title(session: &SessionSnapshot, project_name: &str) -> Option { - let own = [ + [ Some(session.name.as_str()), session.branch.as_deref(), session.worktree.as_deref(), ] .into_iter() .flatten() - .find(|value| !value.trim().is_empty()) - .map(str::to_string); - let title = own.or_else(|| { + .find(|value| !value.trim().is_empty() && *value != project_name) + .map(str::to_string) + .or_else(|| { (project_directory_label(project_name, &session.cwd) != project_name) .then(|| directory_name(&session.cwd)) - })?; - (title != project_name).then_some(title) + }) + .filter(|title| title != project_name) } /// Where a separator-bounded prefix shared by all sibling titles ends. The cut @@ -541,6 +541,11 @@ fn shared_prefix_cut(titles: &[&str]) -> Option { while !first.is_char_boundary(common) { common -= 1; } + // A title that ends inside the shared part has no distinguishing rest + // (identical titles included), so nothing may be dimmed away. + if titles.iter().any(|title| title.len() <= common) { + return None; + } let mut cut = first[..common].rfind(is_title_separator)? + 1; while titles .iter() diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index e9ccc234878..26e452be7e9 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -643,3 +643,21 @@ fn directory_title_never_repeats_the_project_name() { "a distinct directory is the session title on its own" ); } + +#[test] +fn identical_titles_are_never_shortened() { + let title = "vault-curator-inbox-2026-10-06-0300"; + assert_eq!(shared_prefix_cut(&[title, title]), None); + assert_eq!( + shared_prefix_cut(&["nightly-sync", "nightly-sync-2026-10-06"]), + None, + "a title that ends inside the shared part has nothing left to show" + ); +} + +#[test] +fn session_name_repeating_the_project_falls_back_to_its_branch() { + let mut named = tree_agent("a", "/work/proj", Some("feat/x"), SessionState::Active); + named.name = "proj".into(); + assert_eq!(session_title(&named, "proj").as_deref(), Some("feat/x")); +} From 4adea5fe2464ec590129327194622af1c46361cc Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:58:34 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(cockpit):=20Tonwert=20zusammengelegter?= =?UTF-8?q?=20Zeilen,=20fester=20Rest=20bei=20Pr=C3=A4fixk=C3=BCrzung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befunde aus spec-check (Lauf 6): - Eine zusammengelegte Projektzeile mit mehreren Agenten übernimmt den Titel-Tonwert ihrer Session (laufend/wartend hell, ruhend gedämpft). - Bei einem gekürzten gemeinsamen Präfix bleiben vom unterscheidenden Rest immer mindestens acht Zeichen fest stehen; nur dessen Mitte schrumpft. - PRODUCT/TECH/HTML präzisieren: Die Zeile des Agenten der fokussierten Pane trägt die Hinterlegung, eine zugeklappte Zeile, die ihn verbirgt, an seiner Stelle. Refs #505 --- app/src/cockpit/panel.rs | 42 ++++++++++++++++--- app/src/cockpit/panel_tests.rs | 52 ++++++++++++++++++++++++ docs/ui/cockpit-sessions-tree.html | 4 +- docs/ui/cockpit-sidebar-connections.html | 2 +- specs/GH-160/PRODUCT.md | 5 ++- specs/GH-160/TECH.md | 3 +- 6 files changed, 96 insertions(+), 12 deletions(-) diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index ac2d1aa1282..f4ce8502334 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -428,8 +428,14 @@ fn kept_suffix_start(text: &str) -> Option { if text.chars().count() <= MIDDLE_CLIP_MIN_CHARS { return None; } + kept_suffix(text, MIN_KEPT_SEGMENT_CHARS) +} + +/// The separator where a kept end of at least `min_chars` (and at most +/// [`MAX_KEPT_SEGMENT_CHARS`]) characters starts. +fn kept_suffix(text: &str, min_chars: usize) -> Option { let mut start = text.rfind(is_title_separator)?; - while text[start..].chars().count() < MIN_KEPT_SEGMENT_CHARS { + while text[start..].chars().count() < min_chars { start = text[..start].rfind(is_title_separator)?; } (start > 0 && text[start..].chars().count() <= MAX_KEPT_SEGMENT_CHARS).then_some(start) @@ -455,6 +461,9 @@ enum TreeRowKind<'a> { Project { key: String, label: TreeLabel, + /// The state of its only session when the project row is that + /// session (merged); its title then takes the session's tone. + session_state: Option, expanded: bool, count: usize, has_waiting: bool, @@ -601,10 +610,20 @@ fn split_title(title: &str, cut: Option) -> TreeLabel { PartTone::Dim, PartFit::Shrinks, )]; - if rest.chars().count() <= MAX_KEPT_SEGMENT_CHARS { - parts.push(label_part(rest, PartTone::Title, PartFit::Fixed)); - } else { - parts.extend(middle_clipped(rest, PartTone::Title)); + // The distinguishing rest keeps at least MIN_DISTINCT_TAIL_CHARS fixed: + // whole when short, otherwise its end, with only the middle shrinking. + match kept_suffix(rest, MIN_DISTINCT_TAIL_CHARS) + .filter(|_| rest.chars().count() > MAX_KEPT_SEGMENT_CHARS) + { + Some(start) => { + parts.push(label_part( + &rest[..start], + PartTone::Title, + PartFit::Shrinks, + )); + parts.push(label_part(&rest[start..], PartTone::Title, PartFit::Fixed)); + } + None => parts.push(label_part(rest, PartTone::Title, PartFit::Fixed)), } TreeLabel { parts, @@ -676,6 +695,7 @@ fn project_tree_rows<'a>( kind: TreeRowKind::Project { key: project_key, label, + session_state: Some(only.state), expanded: project_expanded, count: only.agents.len(), has_waiting: only.needs_me > 0, @@ -701,6 +721,7 @@ fn project_tree_rows<'a>( kind: TreeRowKind::Project { key: project_key, label: TreeLabel::plain(project_name), + session_state: None, expanded: project_expanded, count: sessions.len(), has_waiting: sessions.iter().any(|session| session.needs_me > 0), @@ -1410,6 +1431,7 @@ impl CockpitPanel { TreeRowKind::Project { key, label, + session_state, expanded, count, has_waiting, @@ -1417,6 +1439,7 @@ impl CockpitPanel { } => self.render_project_header( key, label, + *session_state, *count, *has_waiting, *expanded, @@ -1750,6 +1773,7 @@ impl CockpitPanel { &self, pkey: &str, label: &TreeLabel, + session_state: Option, count: usize, has_waiting: bool, expanded: bool, @@ -1788,7 +1812,13 @@ impl CockpitPanel { let pkey_owned = pkey.to_string(); let label = label.clone(); Hoverable::new(handle, move |mouse| { - let name_color = if mouse.is_hovered() { main_c } else { muted_c }; + // A project row reads as a muted group label; a merged row is its + // only session and takes that session's title tone instead. + let name_color = match session_state.map(title_tone) { + _ if mouse.is_hovered() => main_c, + Some(TitleTone::Active) => main_c, + Some(TitleTone::Quiet) | None => muted_c, + }; let mut row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(6.0) diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index 26e452be7e9..e62e3205cfc 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -661,3 +661,55 @@ fn session_name_repeating_the_project_falls_back_to_its_branch() { named.name = "proj".into(); assert_eq!(session_title(&named, "proj").as_deref(), Some("feat/x")); } + +#[test] +fn shared_prefix_keeps_at_least_eight_distinguishing_characters_fixed() { + let label = split_title("team-sync-feature-checkout-redesign-step-2", Some(10)); + let parts: Vec<_> = label + .parts + .iter() + .map(|part| (part.text.as_str(), part.tone, part.fit)) + .collect(); + assert_eq!( + parts, + vec![ + ("team-sync-", PartTone::Dim, PartFit::Shrinks), + ("feature-checkout", PartTone::Title, PartFit::Shrinks), + ("-redesign-step-2", PartTone::Title, PartFit::Fixed), + ] + ); + let fixed = label.parts.last().unwrap().text.chars().count(); + assert!(fixed >= MIN_DISTINCT_TAIL_CHARS); +} + +#[test] +fn merged_multi_agent_row_takes_the_session_tone() { + let agents = [ + in_one_pty(tree_agent( + "a", + "/work/proj", + Some("main"), + SessionState::Active, + )), + in_one_pty(tree_agent( + "b", + "/work/proj", + Some("main"), + SessionState::Idle, + )), + ]; + let rows = project_tree_rows( + "proj", + group_project_sessions(false, Some("host-a"), &agents), + "host-a\u{1f}/work/proj".to_string(), + true, + |_| true, + |_: &SessionSnapshot| false, + ); + match &rows[0].kind { + TreeRowKind::Project { session_state, .. } => { + assert_eq!(*session_state, Some(SessionState::Active)); + } + other => panic!("expected the merged project row, got {other:?}"), + } +} diff --git a/docs/ui/cockpit-sessions-tree.html b/docs/ui/cockpit-sessions-tree.html index d6aa7bcaa5e..9bf87b3dcae 100644 --- a/docs/ui/cockpit-sessions-tree.html +++ b/docs/ui/cockpit-sessions-tree.html @@ -68,8 +68,8 @@

    Regeln

  • #spec B3 Bei mehreren Sessions ist eine PTY-Session mit einem Agenten eine Zeile: Titel, darunter Provider-Icon, Provider und Modell. Ohne eigenen Titel ist der Agent die einzeilige Überschrift. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen; ihre Zeile trägt kein Sammelsymbol.
  • #spec B4 Teilt ein Titel mit mindestens einem gleichrangigen Titel einen langen, an einem Trennzeichen endenden Präfix, wird dieser 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 (feat/checkout-re…-step-2), sonst am Ende. Kann der sichtbare Titel kürzer sein, steht der volle im Tooltip. Titelteile, Provider und Modell trennen Abstand und Tonwert, nie Trennzeichen wie Mittelpunkte.
  • #spec B5 Jede Zeile reserviert die Chevron- und Symbolspalte; Titel einer Ebene teilen eine Textachse.
  • -
  • #spec B6 Titel ruhender Sessions nutzen die gedämpfte Textrolle; laufende und wartende die primäre. Amber bleibt allein „wartet auf Dich“.
  • -
  • #spec B7 Die Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung über die ganze Zeile. Hover vertieft nur die Farbe.
  • +
  • #spec B6 Titel ruhender Sessions nutzen die gedämpfte Textrolle; laufende und wartende die primäre. Eine zusammengelegte Zeile übernimmt den Tonwert ihrer Session. Amber bleibt allein „wartet auf Dich“.
  • +
  • #spec B7 Die Zeile des Agenten der fokussierten Pane trägt eine stabile Akzent-Hinterlegung über die ganze Zeile; verbirgt eine zugeklappte Zeile ihn, trägt diese die Hinterlegung. Hover vertieft nur die Farbe.
  • #spec B8 Gruppen trennen Abstände vor Host und Projekt, keine Linien oder Rahmen. Zähler erscheinen nur an zugeklappten Containern.
  • #spec B9 Baumzeilen öffnen beim Hover keine Vorschau. Aufgaben und Details stehen in der Konto-Pane.
  • diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index 45c8cdf1316..c6a797f731f 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -60,7 +60,7 @@

    Live-Baum

  • #spec T3 Container starten aufgeklappt. Zähler erscheinen nur im zugeklappten Zustand.
  • #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 Session der fokussierten Pane trägt eine stabile Akzent-Hinterlegung. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • +
  • #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 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.
  • diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 37f080a68bc..0cb26cba302 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -90,8 +90,9 @@ The previous static illustrations are superseded, not alternative approved layou 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. The session of the - focused pane keeps a stable accent tint. Groups separate by spacing, not lines. Session rows + 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 + collapsed row that hides it carries the tint instead. Groups separate by spacing, not lines. Session rows with a title are two lines; this supersedes the earlier single-line session row. Reference: [`docs/ui/cockpit-sessions-tree.html`](../../docs/ui/cockpit-sessions-tree.html). diff --git a/specs/GH-160/TECH.md b/specs/GH-160/TECH.md index 3b6a063775d..eb5b06de60d 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -292,7 +292,8 @@ depend on pixels: title is the directory's own name, never `project — directory`. Parts, provider, and model are separated by spacing, never by glyphs; - every row reserves the chevron and glyph columns; idle titles use the muted text role (`title_tone`); the focused pane's session (workspace `ActiveSession::terminal_view_id` matched - through `terminal_for_inventory_session`) keeps a stable accent tint (`style::tree_row`); + through `terminal_for_inventory_session`) keeps a stable accent tint on its agent's row, or on + the collapsed row hiding it (`style::tree_row`); a merged project row takes its session's tone; - tree rows open no hover preview; the task peek remains only in the large account pane table. - no tree leaf renders state words, context percentage, cost, email, effort, or activity age; - the section header renders only an amber glyph and numeric count when attention exists. From 6ed741536063215ade5e30e2778fcb4562afe577 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:07:31 +0200 Subject: [PATCH 8/8] =?UTF-8?q?fix(cockpit):=20Hostzeile=20tr=C3=A4gt=20Fo?= =?UTF-8?q?kus=20und=20Tooltip,=20leeres=20cwd=20ist=20kein=20Titel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eine zugeklappte Hostgruppe, die den Agenten der fokussierten Pane verbirgt, trägt jetzt die Akzent-Hinterlegung (wie Projekt- und Session-Gruppen). - Der Hosttitel zeigt den vollen Namen im Tooltip, auch wenn er gekürzt wird. - Ein leeres oder nur aus Leerzeichen bestehendes Arbeitsverzeichnis ergibt keinen Sessiontitel mehr. Refs #505 --- app/src/cockpit/panel.rs | 60 ++++++++++++++++++++++++++-------- app/src/cockpit/panel_tests.rs | 8 +++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/app/src/cockpit/panel.rs b/app/src/cockpit/panel.rs index f4ce8502334..c602230fe8b 100644 --- a/app/src/cockpit/panel.rs +++ b/app/src/cockpit/panel.rs @@ -259,7 +259,7 @@ pub struct CockpitPanel { conductor_session_states: HashMap, expanded_sessions: HashMap, /// Tooltip hover state for a row title; keyed by agent key (leaf rows), - /// session key (multi-agent rows), or project key (project rows). + /// session key (multi-agent rows), project key, or host identity. conductor_title_states: HashMap, } @@ -526,7 +526,7 @@ fn session_title(session: &SessionSnapshot, project_name: &str) -> Option Box { let theme = appearance.theme(); @@ -1081,7 +1088,21 @@ impl CockpitPanel { appearance, )) .with_child( - Shrinkable::new(1.0, Self::identity_text(label, family, body, main)).finish(), + Shrinkable::new( + 1.0, + appearance.ui_builder().overlay_tool_tip_on_element( + label.clone(), + self.conductor_title_states + .get(key) + .cloned() + .unwrap_or_default(), + Self::identity_text(label, family, body, main), + ParentAnchor::TopMiddle, + ChildAnchor::BottomMiddle, + vec2f(0.0, -4.0), + ), + ) + .finish(), ); if let Some(count) = container_count_presentation(expanded, count, host.needs_me) { let color = if count.attention { @@ -1099,7 +1120,7 @@ impl CockpitPanel { .unwrap_or_default(); let key = key.to_string(); Hoverable::new(handle, move |mouse| { - hover_row(row, mouse.is_hovered(), appearance) + tree_row(row, mouse.is_hovered(), focused, appearance) }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { @@ -1564,8 +1585,26 @@ impl CockpitPanel { let host_id = host.host_id.as_deref(); let ident = host_ident(is_local, host_id); let host_expanded = self.expanded_hosts.get(&ident).copied().unwrap_or(true); - let mut host_row = - Container::new(self.render_host_header(host, &ident, host_expanded, appearance)); + let is_focused = |agent: &SessionSnapshot| { + focused_terminal.is_some() + && terminal_for_inventory_session(agent, is_local, host_id, app) + == focused_terminal + }; + // A collapsed host that hides the focused pane's agent carries + // its tint instead. + let host_focused = !host_expanded + && host + .projects + .iter() + .flat_map(|project| project.sessions.iter()) + .any(|agent| is_focused(agent)); + let mut host_row = Container::new(self.render_host_header( + host, + &ident, + host_expanded, + host_focused, + appearance, + )); if host_index > 0 { host_row = host_row.with_margin_top(HOST_GROUP_GAP); } @@ -1573,11 +1612,6 @@ impl CockpitPanel { if !host_expanded { continue; } - let is_focused = |agent: &SessionSnapshot| { - focused_terminal.is_some() - && terminal_for_inventory_session(agent, is_local, host_id, app) - == focused_terminal - }; for project in &host.projects { let pkey = project_key(&ident, &project.root); let project_expanded = self.expanded_projects.get(&pkey).copied().unwrap_or(true); diff --git a/app/src/cockpit/panel_tests.rs b/app/src/cockpit/panel_tests.rs index e62e3205cfc..056843fca26 100644 --- a/app/src/cockpit/panel_tests.rs +++ b/app/src/cockpit/panel_tests.rs @@ -662,6 +662,14 @@ fn session_name_repeating_the_project_falls_back_to_its_branch() { assert_eq!(session_title(&named, "proj").as_deref(), Some("feat/x")); } +#[test] +fn empty_working_directory_never_becomes_a_title() { + let agent = tree_agent("a", "", None, SessionState::Active); + assert_eq!(session_title(&agent, "proj"), None); + let blank = tree_agent("b", " ", None, SessionState::Active); + assert_eq!(session_title(&blank, "proj"), None); +} + #[test] fn shared_prefix_keeps_at_least_eight_distinguishing_characters_fixed() { let label = split_title("team-sync-feature-checkout-redesign-step-2", Some(10));