diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index 7b6d3bb3a93..d2e871e1a37 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -16,7 +16,12 @@ 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-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. @@ -47,6 +52,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 207cfd70bbd..9d9f3e29fcb 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -2832,7 +2832,12 @@ 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 +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 -> @@ -4003,6 +4008,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/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..c602230fe8b 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,43 @@ 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, 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::{ - accessibility::{AccessibilityContent, WarpA11yRole}, - AfterLayoutContext, AppContext, BlurContext, Entity, EventContext, FocusContext, LayoutContext, - PaintContext, SingletonEntity, SizeConstraint, TypedActionView, View, ViewContext, ViewHandle, - WindowId, + AfterLayoutContext, AppContext, Entity, EntityId, 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, 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::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, utilisation_coloru, - utilisation_track, zone_card, BLOCK_RADIUS, 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::view_components::action_button::{ActionButton, ButtonSize, PaneHeaderTheme}; +use crate::workspace::ActiveSession; 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 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); @@ -79,7 +76,7 @@ fn container_count_presentation( }) } -fn account_count_presentation( +pub(super) fn account_count_presentation( health: &zaplex_cockpit::ScanHealth, account_count: usize, ) -> Option { @@ -228,156 +225,27 @@ 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, + /// Terminal of this window's focused pane; its session gets the stable + /// highlight (#505). + focused_terminal: Option, 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. 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. + /// clickable row handle 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. @@ -390,6 +258,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 row title; keyed by agent key (leaf rows), + /// session key (multi-agent rows), project key, or host identity. + conductor_title_states: HashMap, } /// Stable identity of a project group within the tree: the host identity plus @@ -402,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() { @@ -468,14 +325,464 @@ 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) +/// 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 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 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 { + parts: middle_clipped(text, PartTone::Title), + full: text.to_string(), + } + } +} + +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; + } + 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_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. A project + /// with a single multi-agent session carries that session's title too. + 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, + /// 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. + 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, + /// Collapsed and hiding the focused pane's agent. + focused: bool, + }, + /// 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 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 { + [ + Some(session.name.as_str()), + session.branch.as_deref(), + session.worktree.as_deref(), + ] + .into_iter() + .flatten() + .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)) + }) + .filter(|title| !title.trim().is_empty() && title != project_name) +} + +/// 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; + } + // 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() + .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) +} + +/// 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 { + 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}…") +} + +/// 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 { + 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, + )]; + // 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, + 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 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; a long title keeps its last segment. +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] = 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(label), + agent, + focused: is_focused(agent), + }, + }); + return rows; + } + rows.push(TreeRow { + depth: 1, + 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, + focused: !project_expanded && only.agents.iter().any(|agent| is_focused(*agent)), + }, + }); + 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, + label: TreeLabel::plain(project_name), + session_state: None, + 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 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, cuts.next().flatten())); + 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, + focused: !expanded && session.agents.iter().any(|agent| is_focused(*agent)), + }, }); + 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. ctx.subscribe_to_model(&Appearance::handle(ctx), |_, _, _, ctx| ctx.notify()); ctx.subscribe_to_model(&AccessibilitySettings::handle(ctx), |_, _, _, ctx| { @@ -484,7 +791,16 @@ 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(); + } + }); + // The focused pane's session carries a stable highlight in the tree. + // `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(); } }); @@ -498,43 +814,24 @@ 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(), - 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(), + conductor_title_states: 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) { @@ -595,29 +892,21 @@ 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) + || project_keys.contains(k) + || host_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(); } - // 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)); @@ -626,7 +915,10 @@ impl CockpitPanel { self.expanded_hosts.retain(|key, _| host_keys.contains(key)); for key in host_keys { self.conductor_host_states.entry(key.clone()).or_default(); - self.conductor_host_glyph_states.entry(key).or_default(); + self.conductor_host_glyph_states + .entry(key.clone()) + .or_default(); + self.conductor_title_states.entry(key).or_default(); } // Project-group header handles + collapse overrides, keyed by // `project_key` (host identity + repository root — never the label alone). @@ -637,18 +929,24 @@ 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)); 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(); } } - fn text( + pub(super) fn text( s: String, family: warpui::fonts::FamilyId, size: f32, @@ -657,7 +955,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,298 +994,428 @@ 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, + /// 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. + pub(super) fn render_zone_header( + label: String, + count: Option, + trailing: Option>, + background: Fill, 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); + let section_size = appearance.ui_font_footnote(); + let faint = theme + .sub_text_color(background) + .with_opacity(55) + .into_solid(); + let muted = theme.sub_text_color(background).into_solid(); - Flex::row() + let mut row = 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) + // The label is scaffolding, not content: muted + uppercase, so the eye + // reads it as structure and skips to the rows (spec v3 §0). .with_child(Self::text( - heat_pct_label_with_provenance(fraction, provenance), + label.to_uppercase(), family, - size, - bar_color, - )) - .with_main_axis_size(MainAxisSize::Max) - .finish() + section_size, + muted, + )); + if let Some(count) = count { + row = row.with_child( + Shrinkable::new( + 1.0, + Self::text(count.to_string(), family, section_size, faint), + ) + .finish(), + ); + } + if let Some(trailing) = trailing { + row = row.with_child(trailing); + } + row.with_main_axis_size(MainAxisSize::Max).finish() } - fn render_card( + fn render_host_header( &self, - acct: &AccountUsage, - is_selected: bool, + host: &HostNode, + key: &str, + expanded: bool, + focused: 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 body = appearance.ui_font_body_large(); 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() + 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 label = host_display_label( + host, + &crate::t!("cockpit-host-removed"), + &crate::t!("cockpit-host-unverified"), + ); + let count = host_conductor_session_count(host); + 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( - Rect::new() - .with_background_color(theme.accent().into_solid()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.0))) + chevron + .to_warpui_icon(theme.sub_text_color(theme.surface_1())) .finish(), ) - .with_width(12.0) - .with_height(12.0) + .with_width(GLYPH_COL_WIDTH) + .with_height(GLYPH_COL_WIDTH) .finish(), ) + .with_child(Self::host_status_dot( + host, + self.conductor_host_glyph_states + .get(key) + .cloned() + .unwrap_or_default(), + appearance, + )) .with_child( Shrinkable::new( 1.0, - Self::text(identity.provider.to_string(), family, provider_size, main), + 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(), - ) - .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, - )); + ); + if let Some(count) = container_count_presentation(expanded, count, host.needs_me) { + let color = if count.attention { + attention_coloru(appearance) + } else { + faint + }; + row = row.with_child(Self::text(count.count.to_string(), family, body, color)); } - 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 row = row.with_main_axis_size(MainAxisSize::Max).finish(); let handle = self - .card_states - .get(&acct.account.key) + .conductor_host_states + .get(key) .cloned() .unwrap_or_default(); - let key = acct.account.key.clone(); + let key = key.to_string(); 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() + tree_row(row, mouse.is_hovered(), focused, appearance) }) - .with_cursor(warpui::platform::Cursor::PointingHand) + .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(CockpitPanelAction::SelectAccount(key.clone())) + ctx.dispatch_typed_action(CockpitPanelAction::ToggleHost(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 - /// 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. - fn render_zone_header( - label: String, - count: Option, - trailing: Option>, - background: Fill, + /// 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() + } + + /// 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, + size: f32, + color: ColorU, + appearance: &Appearance, + ) -> Box { + let family = appearance.ui_font_family(); + let theme = appearance.theme(); + let dim = theme + .sub_text_color(theme.surface_1()) + .with_opacity(55) + .into_solid(); + let mut line = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .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 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, + tooltip_key: &str, + size: f32, + color: ColorU, + appearance: &Appearance, + ) -> Box { + let line = Self::label_parts(label, size, color, appearance); + 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, 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, + headline: Option, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let family = appearance.ui_font_family(); - let section_size = appearance.ui_font_footnote(); + let footnote = appearance.ui_font_footnote(); + let muted = theme.sub_text_color(theme.surface_1()).into_solid(); let faint = theme - .sub_text_color(background) + .sub_text_color(theme.surface_1()) .with_opacity(55) .into_solid(); - let muted = theme.sub_text_color(background).into_solid(); - - let mut row = Flex::row() + 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 (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_spacing(6.0) - // The label is scaffolding, not content: muted + uppercase, so the eye - // reads it as structure and skips to the rows (spec v3 §0). + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(5.0) + .with_child(icon) .with_child(Self::text( - label.to_uppercase(), + presentation.provider.to_string(), family, - section_size, - muted, + provider_size, + provider_color, )); - if let Some(count) = count { - row = row.with_child( + let line = match presentation.model { + Some(model) => line.with_child( Shrinkable::new( 1.0, - Self::text(count.to_string(), family, section_size, faint), + Self::identity_text(model.to_string(), family, footnote, model_color), ) .finish(), - ); - } - if let Some(trailing) = trailing { - row = row.with_child(trailing); + ), + None => line, + }; + 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), } - row.with_main_axis_size(MainAxisSize::Max).finish() } - fn render_host_header( + /// 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, - host: &HostNode, key: &str, + label: Option<&TreeLabel>, + state: SessionState, expanded: bool, + count: usize, + needs_me: usize, + focused: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let family = appearance.ui_font_family(); - let body = appearance.ui_font_body_large(); - let main = theme.main_text_color(theme.surface_1()).into_solid(); + let body = appearance.ui_font_body(); + 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 label = host_display_label( - host, - &crate::t!("cockpit-host-removed"), - &crate::t!("cockpit-host-unverified"), - ); - let count = host_conductor_session_count(host); 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(Self::host_status_dot( - host, - self.conductor_host_glyph_states - .get(key) - .cloned() - .unwrap_or_default(), - appearance, - )) - .with_child( - Shrinkable::new(1.0, Self::identity_text(label, family, body, main)).finish(), - ); - if let Some(count) = container_count_presentation(expanded, count, host.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 { @@ -995,100 +1423,117 @@ 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_host_states + .conductor_session_states .get(key) .cloned() .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, _, _| { - ctx.dispatch_typed_action(CockpitPanelAction::ToggleHost(key.clone())) + ctx.dispatch_typed_action(CockpitPanelAction::ToggleSession(key.clone())) }) .finish() } - fn render_session_header( + fn render_tree_row( &self, - session: &ConductorSession<'_>, - project_name: &str, - expanded: bool, + row: &TreeRow<'_>, + host: &HostNode, + managed_fleet: &ManagedFleetInventory, + animate_waiting: bool, 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 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 element = match &row.kind { + TreeRowKind::Project { + key, + label, + session_state, + expanded, + count, + has_waiting, + focused, + } => self.render_project_header( + key, + label, + *session_state, + *count, + *has_waiting, + *expanded, + *focused, + 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, + focused, + } => self.render_session_container( + key, + label.as_ref(), + *state, + *expanded, + *count, + *needs_me, + *focused, + appearance, + ), + TreeRowKind::Agent { agent, focused } => self.render_agent_leaf( + host, + agent, + None, + *focused, + managed_fleet, + animate_waiting, + appearance, + ), }; - let mut row = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .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) - { - let color = if count.attention { - attention_coloru(appearance) - } else { - faint - }; - row = row.with_child(Self::text(count.count.to_string(), family, body, color)); + let mut container = + Container::new(element).with_padding_left(TREE_DEPTH_INDENT * row.depth as f32); + // Every project group gets the same air, merged single-row ones too. + if row.depth == 1 { + container = container.with_margin_top(PROJECT_GROUP_GAP); } - let row = row.with_main_axis_size(MainAxisSize::Max).finish(); - let handle = self - .conductor_session_states - .get(&session.key) - .cloned() - .unwrap_or_default(); - let key = session.key.clone(); - Hoverable::new(handle, move |mouse| { - hover_row(row, mouse.is_hovered(), appearance) - }) - .with_cursor(Cursor::PointingHand) - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(CockpitPanelAction::ToggleSession(key.clone())) - }) - .finish() + 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(); @@ -1135,83 +1580,57 @@ 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 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); + } + col = col.with_child(host_row.finish()); if !host_expanded { continue; } 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() { @@ -1241,142 +1660,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, @@ -1519,13 +1802,16 @@ 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, - name: &str, + label: &TreeLabel, + session_state: Option, count: usize, has_waiting: bool, expanded: bool, + focused: bool, appearance: &Appearance, ) -> Box { let family = appearance.ui_font_family(); @@ -1552,10 +1838,21 @@ 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 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 }; + // 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) @@ -1565,13 +1862,20 @@ 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, - Text::new_inline(name_s.clone(), family, body) - .with_color(name_color) - .with_clip(ClipConfig::ellipsis()) - .finish(), + 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(), ); @@ -1587,9 +1891,10 @@ impl CockpitPanel { .finish(), ); } - hover_row( + tree_row( row.with_main_axis_size(MainAxisSize::Max).finish(), mouse.is_hovered(), + focused, appearance, ) }) @@ -1599,32 +1904,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,34 +1911,28 @@ 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 // 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, + self.focused_terminal, + app, + appearance, + ) { zone_card(conductor, appearance) .with_uniform_padding(CARD_PADDING) .finish() @@ -1667,74 +1940,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 +1953,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 +1962,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 +1976,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 +1983,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 +1999,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..056843fca26 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 { @@ -403,3 +289,435 @@ 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 { label, focused, .. } => { + (row.depth, "project", label.full.clone(), *focused) + } + 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, focused, .. } => ( + row.depth, + "session", + label.as_ref().map_or_else(String::new, |l| l.full.clone()), + *focused, + ), + 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, + )), + 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)); + 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!( + 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 + .iter() + .any(|row| row.1 == "session" && row.3)); + let expanded_focused = local_project_rows("proj", &agents, true, Some("b")); + 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), + ] + ); + + // Short titles, and long ones without a short last segment, end-clip. + assert_eq!(TreeLabel::plain("main").parts.len(), 1); + assert_eq!( + TreeLabel::plain("averyveryverylongbranchnamewithoutseparators") + .parts + .len(), + 1 + ); +} + +#[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); + 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 { + 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. + 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)); +} + +#[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]).parts.last().unwrap().text, + "10-06-0300" + ); + assert_eq!( + split_title(titles[2], cuts[2]).parts.last().unwrap().text, + "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" + ); +} + +#[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")); +} + +#[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)); + 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/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/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..4ed51e81f20 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,10 @@ pub struct LeftPanelView { server_file_browser_view: ViewHandle, skill_manager_view: ViewHandle, cockpit_view: ViewHandle, + cockpit_accounts_view: ViewHandle, + /// 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, @@ -289,6 +301,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, fleet_waiting: bool) -> bool { + fleet_waiting && active_view != ToolPanelView::Cockpit +} + fn view_remains_available(active_view: ToolPanelView, available_views: &[ToolPanelView]) -> bool { available_views .iter() @@ -340,11 +359,24 @@ 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; 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; + if waiting != me.fleet_waiting { + me.fleet_waiting = waiting; + ctx.notify(); + } + } + }); + 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 { @@ -521,6 +553,8 @@ impl LeftPanelView { server_file_browser_view, skill_manager_view, cockpit_view, + cockpit_accounts_view, + fleet_waiting, active_view: active_view_state::new(active_view), available_views: views, toolbelt_buttons, @@ -697,6 +731,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 +1024,9 @@ impl LeftPanelView { ToolPanelView::Cockpit => { ctx.focus(&self.cockpit_view); } + ToolPanelView::CockpitAccounts => { + ctx.focus(&self.cockpit_accounts_view); + } } } @@ -1156,6 +1205,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, }; } @@ -1164,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(); @@ -1181,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 { @@ -1226,6 +1276,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 +1374,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 +1488,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 +1507,10 @@ 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(), 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. @@ -1450,7 +1521,32 @@ 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) + if sessions_attention + && matches!(button_config.action, LeftPanelAction::Cockpit) + { + // 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 { + Self::render_button( + button_config, + mouse_state.clone(), + button_config.tooltip_text.clone(), + appearance, + ) + } }, )) .with_main_axis_size(MainAxisSize::Min) @@ -1529,6 +1625,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..312780469f4 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,44 @@ 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, + true + )); + assert!(sessions_entry_shows_attention( + ToolPanelView::SshManager, + true + )); + assert!(!sessions_entry_shows_attention( + ToolPanelView::Cockpit, + true + )); + assert!(!sessions_entry_shows_attention( + ToolPanelView::CockpitAccounts, + false + )); +} + +#[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..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 @@ -67,8 +68,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-sessions-tree.html b/docs/ui/cockpit-sessions-tree.html new file mode 100644 index 00000000000..9bf87b3dcae --- /dev/null +++ b/docs/ui/cockpit-sessions-tree.html @@ -0,0 +1,131 @@ + + + + +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 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. 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.
  • +
+
+ +

Referenzansichten

+
+
+

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

+ +
+ +
+

„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 b72076f0ff4..c6a797f731f 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.
@@ -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 PTY-Session ist eine Zeile; der Projektname führt, der eigene Titel der Session folgt gedimmt mit Abstand und weicht zuerst. Bei mehreren Sessions ist eine PTY-Session mit einem Agenten eine Zeile mit Titel und darunter Provider-Icon, Provider und Modell; ohne eigenen Titel ist der Agent die einzeilige Überschrift. Nur PTY-Sessions mit mehreren Agenten behalten Agentzeilen, ohne Sammelsymbol. Referenz: cockpit-sessions-tree.html.
  • +
  • #spec T6 Ein langer Präfix, den ein Titel mit mindestens einem gleichrangigen Titel teilt, wird gekürzt und gedimmt; mindestens acht unterscheidende Zeichen bleiben sichtbar. Ein langer Titel behält sein kurzes letztes Segment und kürzt in der Mitte. Kann der sichtbare Titel kürzer sein, steht der volle im Tooltip. Titelteile, Provider und Modell trennen Abstand und Tonwert, keine Trennzeichen wie Mittelpunkte.
  • +
  • #spec T7 Feste Chevron- und Symbolspalten: Titel einer Ebene teilen eine Textachse. Ruhende Sessions nutzen die gedämpfte Textrolle; die Zeile des Agenten der fokussierten Pane trägt eine stabile Akzent-Hinterlegung, eine zugeklappte Zeile, die ihn verbirgt, an seiner Stelle. Gruppen trennen Abstände, keine Linien. Baumzeilen öffnen beim Hover keine Vorschau.
  • #spec 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 189f536b78c..0cb26cba302 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. @@ -75,6 +76,26 @@ 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 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, 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). + 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. @@ -336,8 +357,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..eb5b06de60d 100644 --- a/specs/GH-160/TECH.md +++ b/specs/GH-160/TECH.md @@ -277,7 +277,24 @@ 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 + 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), and a long title keeps its last segment fixed while the head + 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 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. @@ -452,10 +469,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.