From 0d1fb192fcfcab6e1ec541952c71f92ef0687cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 11 Oct 2026 22:31:31 -0300 Subject: [PATCH 01/17] Session card: side chats and subagents ride a lanes card under it The explorer's Subagents / Chats footer moves into a card of its own, stacked under the session card and shown with it. It lists the open chat's side chats and subagents in session-card rows, each lane's status marks joined by one thin track. New side chat and Fork chat close the side-chat lane; settled subagents fold into one "finished" row. - The two cards share the height below the titlebar, each scrolling inside what it gets. - Renaming a side chat in place now shows the card and pages its lane to the row, instead of needing the explorer open. - Running subagents badge the session card's toggle while the card is closed, with no breathing glow, in place of the explorer button's mark. - The explorer footer, its `FilesEvent` variants and the rename handoff to the files surface are gone. --- crates/ui/src/files/mod.rs | 30 - crates/ui/src/files/sections.rs | 1725 -------------------------- crates/ui/src/files/subagents.rs | 249 ---- crates/ui/src/running_pill.rs | 21 +- crates/ui/src/shell.rs | 51 +- crates/ui/src/shell/files_panel.rs | 28 - crates/ui/src/shell/session_info.rs | 69 +- crates/ui/src/shell/session_lanes.rs | 872 +++++++++++++ crates/ui/src/shell/tabs.rs | 18 +- docs/mcp.md | 9 +- 10 files changed, 942 insertions(+), 2130 deletions(-) delete mode 100644 crates/ui/src/files/sections.rs delete mode 100644 crates/ui/src/files/subagents.rs create mode 100644 crates/ui/src/shell/session_lanes.rs diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index f96e3e3f44..7f65d9fad9 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -32,8 +32,6 @@ pub mod mutations; pub mod preview; mod rename; pub mod search; -mod sections; -mod subagents; #[cfg(test)] pub(crate) mod test_support; pub mod tree; @@ -213,24 +211,6 @@ pub enum FilesEvent { ShowAllFilesChanged(bool), CloseReady, CloseCancelled, - /// A footer row: open this subagent's transcript in the right pane. - OpenSubagent { - doc_id: String, - title: String, - frozen: bool, - }, - /// A footer row: open this side chat (by id) in the right pane. - OpenChildChat(String), - /// A footer row's double-click: edit this side chat's title in place. - RenameChildChat(String), - ChildChatContextMenu { - chat_id: String, - position: Point, - }, - /// The Chats header's "+": start a fresh side chat of the active chat. - NewChildChat, - /// The Chats header's fork: fork the active chat into a side chat. - ForkChat, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -306,8 +286,6 @@ pub struct FilesSurface { loads: HashMap<(String, Option), Task<()>>, error: Option, started: bool, - /// The Subagents / Chats footer under the tree. - sections: sections::ExplorerSections, _observe: Subscription, _search_events: Subscription, } @@ -336,9 +314,6 @@ impl Render for FilesSurface { self.render_explorer(&theme, cx).into_any_element() }; let editor_context_menu = self.render_editor_context_menu(&theme, cx); - // The explorer docks its Subagents / Chats sections under the tree; - // an editor surface has no footer. - let sections = (!is_editor).then(|| self.render_sections(&theme, cx)); div() .id(SharedString::from(format!( "files-surface-{}", @@ -358,7 +333,6 @@ impl Render for FilesSurface { .as_ref() .map(|message| crate::popover::error_row(&theme, message).into_any_element()), ) - .children(sections) .children(editor_context_menu) .children(self.render_tree_context_menu(&theme, cx)) .children(self.render_tree_delete(&theme, window, cx)) @@ -633,9 +607,6 @@ impl FilesSurface { this.ensure_loaded(cx); } this.sync_active_markdown_comments(cx); - if !this.presentation.is_editor() { - this.refresh_sections(cx); - } }); // The list state exposes no scroll handle, so the floating rail // bridges scroll activity through the scroll handler (the @@ -698,7 +669,6 @@ impl FilesSurface { loads: HashMap::new(), error: None, started: false, - sections: sections::ExplorerSections::default(), _observe: observe, _search_events: search_events, }; diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs deleted file mode 100644 index de9f865e12..0000000000 --- a/crates/ui/src/files/sections.rs +++ /dev/null @@ -1,1725 +0,0 @@ -//! The explorer's footer: two collapsible sections docked under the file -//! tree — **Subagents** (the spawn chips of the active chat's transcript, -//! with their live status) and **Chats** (the side chats hanging off the -//! active chat: forks, and chats an agent spawned through the Zeron MCP -//! server). Rows borrow the left sidebar's compact session row — 29px, status -//! glyph, title, time — minus the harness, project and device icons, which -//! say nothing here (every row shares the parent's context). Clicking a row -//! opens it in the right pane's surface host; the Chats header carries "+" -//! and fork beside its caret; the section chrome animates with the same -//! collapse motion as the sidebar's disclosures. -//! -//! The footer has a fixed height budget that the open sections share and -//! scroll inside, and like the sidebar's Archived shelf each shows ten rows -//! before a "Show N more" row pages by ten. - -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; - -use chrono::{DateTime, Utc}; -use gpui::{ - Animation, AnimationExt as _, AnyElement, Context, Entity, EntityId, MouseButton, ScrollHandle, - SharedString, div, prelude::*, px, -}; -use zeron_doc::{MessagePart, SubagentStatus}; -use zeron_proto::{Chat, ChatIndicator}; - -use crate::composer::ComposerInput; -use crate::icons::{self, icon}; -use crate::state::AppState; -use crate::theme::Theme; -use crate::{loaders, motion}; - -use super::subagents::{Group, GroupState, SubagentPlan}; -use super::{FilesEvent, FilesSurface}; - -const SECTION_HEADER_HEIGHT: f32 = 28.0; -const SECTION_BODY_INSET: f32 = 4.0; -const ROW_HEIGHT: f32 = 29.0; -const ROW_GAP: f32 = 2.0; -/// Empty state: the copy (two 16px lines so it can wrap in a narrow -/// explorer) and, for Chats, a row of pill actions — left-aligned like the -/// rows it stands in for. -const EMPTY_COPY_HEIGHT: f32 = 36.0; -const EMPTY_ACTIONS_HEIGHT: f32 = 40.0; -const EMPTY_PAD: f32 = 10.0; -/// Fade band under a section list's edges (the sidebar's treatment, scaled -/// to the shorter lists). -const LIST_FADE_BAND: f32 = 16.0; -/// Hover group of a section header (reveals its actions). -const HEADER_GROUP: &str = "files-section-header"; -/// How far each level of the Finished dropdown steps in. -const GROUP_INDENT: f32 = 10.0; -/// Rows a section shows before "Show more" pages it, and the page size — -/// the sidebar's Archived shelf numbers. -pub(super) const INITIAL_ROWS: usize = 10; -pub(super) const PAGE_ROWS: usize = 10; -/// An open section never shrinks below this, so one or two rows still -/// leave the section room to breathe. -const MIN_BODY_HEIGHT: f32 = 120.0; -const FOOTER_PAD_TOP: f32 = 4.0; -const FOOTER_PAD_BOTTOM: f32 = 6.0; -/// The footer's height budget; shorter content shrinks the footer to fit. -const FOOTER_HEIGHT: f32 = 510.0; -const TWEEN_GRACE: std::time::Duration = std::time::Duration::from_millis(120); - -/// Which footer section a motion or toggle addresses. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(super) enum Section { - Subagents, - Chats, -} - -impl Section { - fn key(self) -> &'static str { - match self { - Section::Subagents => "subagents", - Section::Chats => "chats", - } - } - - fn label(self) -> &'static str { - match self { - Section::Subagents => "Subagents", - Section::Chats => "Chats", - } - } -} - -/// One in-flight open/close of a section body (the sidebar's -/// `SidebarDisclosureMotion`, kept local so the explorer owns its own -/// epochs). A re-toggle mid-flight picks up from the painted height. -#[derive(Debug, Clone, Copy)] -struct DisclosureMotion { - epoch: u64, - from: f32, - to: f32, - started: std::time::Instant, -} - -impl DisclosureMotion { - fn current(self) -> f32 { - let total = motion::COLLAPSE.total().as_secs_f32(); - let raw = if total > 0.0 { - self.started.elapsed().as_secs_f32() / total - } else { - 1.0 - }; - motion::lerp(self.from, self.to, motion::COLLAPSE.progress(raw)) - } - - fn animating(self) -> bool { - self.started.elapsed() < motion::COLLAPSE.total() + TWEEN_GRACE - } -} - -/// Footer state on the explorer surface. -#[derive(Debug)] -pub(super) struct ExplorerSections { - open: HashMap, - motion: HashMap, - /// Rows revealed per section ("Show more" pages this up). - shown: HashMap, - /// One scroll handle per section list, so the edge fades can read - /// overflow at paint time. - scroll: HashMap, - /// Open/closed and paging state of the Finished dropdown. - groups: GroupState, - /// Hash of what the footer would draw, so the state observer only - /// re-renders the explorer when a section's contents actually changed — - /// not on every streamed transcript delta. - fingerprint: u64, - /// The side chat whose title the shell is editing in place, and its - /// field — drawn over that row's title. - chat_rename: Option<(String, Entity)>, - /// A section opened to reveal a renamed row: its motion starts on the - /// render that knows the body height. - reveal: Option
, -} - -impl Default for ExplorerSections { - fn default() -> Self { - Self { - open: [(Section::Subagents, true), (Section::Chats, true)] - .into_iter() - .collect(), - motion: HashMap::new(), - shown: HashMap::new(), - scroll: [ - (Section::Subagents, ScrollHandle::new()), - (Section::Chats, ScrollHandle::new()), - ] - .into_iter() - .collect(), - groups: GroupState::default(), - fingerprint: 0, - chat_rename: None, - reveal: None, - } - } -} - -impl ExplorerSections { - pub(super) fn is_open(&self, section: Section) -> bool { - self.open.get(§ion).copied().unwrap_or(true) - } - - fn shown(&self, section: Section) -> usize { - self.shown - .get(§ion) - .copied() - .unwrap_or(INITIAL_ROWS) - .max(INITIAL_ROWS) - } - - fn toggle(&mut self, section: Section, resting: f32, target: f32) { - let previous = self.motion.get(§ion).copied(); - let from = previous - .filter(|m| m.animating()) - .map(DisclosureMotion::current) - .unwrap_or(resting); - let epoch = previous.map_or(1, |m| m.epoch + 1); - self.motion.insert( - section, - DisclosureMotion { - epoch, - from, - to: target, - started: std::time::Instant::now(), - }, - ); - let open = self.is_open(section); - self.open.insert(section, !open); - } - - /// Start the opening motion of a section a reveal opened. - fn begin_reveal(&mut self, section: Section, height: f32) { - if self.reveal != Some(section) || !self.is_open(section) { - return; - } - self.reveal = None; - let epoch = self.motion.get(§ion).map_or(1, |m| m.epoch + 1); - self.motion.insert( - section, - DisclosureMotion { - epoch, - from: 0.0, - to: height, - started: std::time::Instant::now(), - }, - ); - } - - fn scroll(&self, section: Section) -> ScrollHandle { - self.scroll - .get(§ion) - .cloned() - .unwrap_or_else(ScrollHandle::new) - } - - fn live_motion(&self, section: Section) -> Option { - self.motion.get(§ion).copied().filter(|m| m.animating()) - } -} - -/// A spawn chip of the active transcript, as the footer lists it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct SubagentRow { - pub doc_id: String, - pub title: SharedString, - pub status: Option, - /// When the latest turn that spawned (or steered) it was written — the - /// closest thing a subagent has to a last-updated time. - pub spawned_at: DateTime, -} - -impl SubagentRow { - /// A settled subagent is frozen: the tab reads its blob first. - pub fn frozen(&self) -> bool { - matches!( - self.status, - Some(SubagentStatus::Done) | Some(SubagentStatus::Failed) - ) - } - - fn indicator(&self) -> ChatIndicator { - match self.status { - Some(SubagentStatus::Running) => ChatIndicator::Working, - Some(SubagentStatus::Done) => ChatIndicator::Completed, - Some(SubagentStatus::Failed) => ChatIndicator::Errored, - None => ChatIndicator::Idle, - } - } -} - -/// The active chat's subagents, one row per subagent doc: running ones first, -/// longest-running leading, then the settled ones most recently updated first -/// (later spawns lead within one turn). -/// Only genuine spawn chips with a stamped doc ref qualify — the chip IS the -/// index (there is no listing endpoint), and a stray ref on a non-Agent tool -/// must not surface as a phantom subagent. -pub(super) fn subagent_rows(state: &AppState, chat_id: &str) -> Vec { - if state.selected_chat.as_deref() != Some(chat_id) { - return Vec::new(); - } - let mut rows: Vec = Vec::new(); - for entry in &state.transcript { - let spawned_at = - DateTime::::from_timestamp_millis(entry.created_at).unwrap_or_else(Utc::now); - for part in &entry.parts { - let MessagePart::Tool { - call, - subagent_ref: Some(doc_id), - subagent_status, - .. - } = part - else { - continue; - }; - if !call.is_subagent_spawn() { - continue; - } - let row = SubagentRow { - doc_id: doc_id.clone(), - title: crate::transcript::subagent_tab_title(call), - status: *subagent_status, - spawned_at, - }; - match rows.iter_mut().find(|r| r.doc_id == row.doc_id) { - // A reopened (steered) subagent updates its row in place. - Some(existing) => *existing = row, - None => rows.push(row), - } - } - } - // Stable sort over the reversed spawn order: ties keep the later spawn - // on top. - rows.reverse(); - rows.sort_by_key(|row| std::cmp::Reverse(row.spawned_at)); - // Running subagents lead, longest-running first: one started long ago is - // buried under everything spawned since, and is the one to find and steer. - // Oldest-first also keeps the group still as new subagents join at its - // foot. The settled tail keeps the newest-first order above. - let (mut running, settled): (Vec<_>, Vec<_>) = rows - .into_iter() - .partition(|row| row.status == Some(SubagentStatus::Running)); - // Ties keep spawn order, like the group's oldest-first order. - running.reverse(); - running.sort_by_key(|row| row.spawned_at); - running.extend(settled); - running -} - -/// A side chat of the active chat, as the footer lists it. -#[derive(Debug, Clone, PartialEq)] -pub(super) struct ChildChatRow { - pub chat_id: String, - pub device_id: String, - pub title: SharedString, - pub status: ChatIndicator, - pub time_ago: SharedString, - /// The chat's linked pull request, drawn as the sidebar's badge. - pub change_request: Option, - activity: DateTime, -} - -/// The live (unarchived) children of `chat_id`, most recent activity first — -/// the same order the sidebar's Sessions list keeps. -pub(super) fn child_chat_rows( - state: &AppState, - chat_id: &str, - now: DateTime, -) -> Vec { - let mut rows: Vec = state - .chats - .iter() - .filter(|chat| !chat.archived && chat.parent_chat_id.as_deref() == Some(chat_id)) - .map(|chat| { - let activity = chat.last_message_at.unwrap_or(chat.created_at); - ChildChatRow { - chat_id: chat.id.clone(), - device_id: chat.device_id.clone(), - title: child_chat_title(chat).into(), - status: state.display_status_for(chat, now), - time_ago: zeron_proto::view::format_time_ago(activity, now).into(), - change_request: state.change_request_for_chat(chat).cloned(), - activity, - } - }) - .collect(); - rows.sort_by_key(|row| std::cmp::Reverse(row.activity)); - rows -} - -/// A side chat titles itself on its first turn; until then the preview or a -/// placeholder stands in. -pub(super) fn child_chat_title(chat: &Chat) -> String { - chat.title - .clone() - .or_else(|| chat.last_message_preview.clone()) - .unwrap_or_else(|| "New side chat".into()) -} - -/// What the footer would draw for `chat_id`, hashed. Cheap enough to run on -/// every state notification. -pub(super) fn fingerprint(state: &AppState, chat_id: &str, now: DateTime) -> u64 { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - for row in subagent_rows(state, chat_id) { - row.doc_id.hash(&mut hasher); - row.title.as_ref().hash(&mut hasher); - (row.status.map(|s| s as u8)).hash(&mut hasher); - } - 0xC0FFEEu64.hash(&mut hasher); - for row in child_chat_rows(state, chat_id, now) { - row.chat_id.hash(&mut hasher); - row.device_id.hash(&mut hasher); - row.title.as_ref().hash(&mut hasher); - (row.status as u8).hash(&mut hasher); - row.time_ago.as_ref().hash(&mut hasher); - row.change_request - .as_ref() - .map(|pr| (pr.number, pr.state as u8)) - .hash(&mut hasher); - } - hasher.finish() -} - -/// The height a section body wants for `count` rows with `shown` revealed: -/// inset, the visible rows, and a "Show more" row while more remain. Empty -/// sections want their empty-state copy (Chats adds its action row). -pub(super) fn content_height(section: Section, count: usize, shown: usize) -> f32 { - content_height_unfloored(section, count, shown).max(MIN_BODY_HEIGHT) -} - -fn content_height_unfloored(section: Section, count: usize, shown: usize) -> f32 { - if count == 0 { - return SECTION_BODY_INSET - + EMPTY_PAD * 2.0 - + EMPTY_COPY_HEIGHT - + match section { - Section::Chats => EMPTY_ACTIONS_HEIGHT, - Section::Subagents => 0.0, - }; - } - let visible = count.min(shown); - let more = if count > shown { 1 } else { 0 }; - rows_height(visible + more) -} - -/// The height of a list of `slots` row-sized items: the inset, the rows and -/// the gaps between them. -fn rows_height(slots: usize) -> f32 { - SECTION_BODY_INSET + slots as f32 * ROW_HEIGHT + slots.saturating_sub(1) as f32 * ROW_GAP -} - -/// The Subagents body's wanted height: its rows and group headers, or the -/// empty-state copy when nothing is left to list. -fn subagents_height(plan: &SubagentPlan, groups: &GroupState) -> f32 { - match plan.slots(groups) { - 0 => content_height(Section::Subagents, 0, INITIAL_ROWS), - slots => rows_height(slots).max(MIN_BODY_HEIGHT), - } -} - -/// Split the footer's body budget between two open sections: each may take -/// what it wants, and a short one hands its slack to the other. Closed -/// sections get 0. Pure. -pub(super) fn body_budget(budget: f32, wants: [f32; 2], open: [bool; 2]) -> [f32; 2] { - let budget = budget.max(0.0); - let want = |i: usize| if open[i] { wants[i] } else { 0.0 }; - let (a, b) = (want(0), want(1)); - if a + b <= budget { - return [a, b]; - } - let half = budget / 2.0; - let first = a.min(budget - b.min(half)); - let second = b.min(budget - first); - [first, second] -} - -/// The footer's chrome outside the bodies: padding and the two headers. -fn chrome_height() -> f32 { - FOOTER_PAD_TOP + FOOTER_PAD_BOTTOM + 2.0 * SECTION_HEADER_HEIGHT -} - -impl FilesSurface { - /// Show (or clear) the shell's inline rename of one of the Chats rows. - pub(crate) fn set_chat_rename( - &mut self, - rename: Option<(String, Entity)>, - cx: &mut Context, - ) { - let key = |rename: &Option<(String, Entity)>| { - rename - .as_ref() - .map(|(id, input)| (id.clone(), input.entity_id())) - }; - if key(&self.sections.chat_rename) != key(&rename) { - if let Some((chat_id, _)) = &rename { - self.reveal_chat_row(chat_id, cx); - } - self.sections.chat_rename = rename; - cx.notify(); - } - } - - /// Open the Chats section, page it, and scroll its list so `chat_id`'s - /// row shows — the inline rename's field must never sit on a hidden row. - fn reveal_chat_row(&mut self, chat_id: &str, cx: &mut Context) { - let rows = child_chat_rows(self.state.read(cx), &self.chat_id, Utc::now()); - let Some(index) = rows.iter().position(|row| row.chat_id == chat_id) else { - return; - }; - if !self.sections.is_open(Section::Chats) { - self.sections.open.insert(Section::Chats, true); - self.sections.reveal = Some(Section::Chats); - } - // Page in the same steps "Show more" takes. - if index >= self.sections.shown(Section::Chats) { - self.sections.shown.insert( - Section::Chats, - INITIAL_ROWS + (index + 1 - INITIAL_ROWS).div_ceil(PAGE_ROWS) * PAGE_ROWS, - ); - } - self.sections.scroll(Section::Chats).scroll_to_item(index); - } - - /// Re-render only when the footer's contents changed. - pub(super) fn refresh_sections(&mut self, cx: &mut Context) { - let fingerprint = fingerprint(self.state.read(cx), &self.chat_id, Utc::now()); - if fingerprint != self.sections.fingerprint { - self.sections.fingerprint = fingerprint; - cx.notify(); - } - } - - pub(super) fn render_sections(&mut self, theme: &Theme, cx: &mut Context) -> AnyElement { - let now = Utc::now(); - let (subagents, chats) = { - let state = self.state.read(cx); - ( - subagent_rows(state, &self.chat_id), - child_chat_rows(state, &self.chat_id, now), - ) - }; - self.sections.fingerprint = fingerprint(self.state.read(cx), &self.chat_id, now); - let plan = SubagentPlan::new(subagents); - let wants = [ - subagents_height(&plan, &self.sections.groups), - content_height( - Section::Chats, - chats.len(), - self.sections.shown(Section::Chats), - ), - ]; - let open = [ - self.sections.is_open(Section::Subagents), - self.sections.is_open(Section::Chats), - ]; - let budget = FOOTER_HEIGHT - chrome_height(); - let heights = body_budget(budget, wants, open); - let view = cx.entity_id(); - let subagent_body = self.render_subagent_rows(&plan, view, theme, cx); - let subagent_badge = (plan.running() > 0).then(|| { - crate::running_pill::running_pill( - "files-subagents-header", - plan.running() as u32, - theme, - ) - }); - let chat_body = self.render_chat_rows(&chats, theme, cx); - let chats_actions = self.render_chats_header_actions(theme, cx); - div() - .id("files-sections") - .relative() - .flex_none() - .w_full() - .flex() - .flex_col() - .px(px(6.0)) - .pt(px(FOOTER_PAD_TOP)) - .pb(px(FOOTER_PAD_BOTTOM)) - .child(self.render_section( - Section::Subagents, - plan.total(), - wants[0], - heights[0], - None, - subagent_badge, - subagent_body, - theme, - cx, - )) - .child(self.render_section( - Section::Chats, - chats.len(), - wants[1], - heights[1], - Some(chats_actions), - None, - chat_body, - theme, - cx, - )) - .into_any_element() - } - - /// "+" and fork beside the Chats caret: a fresh side chat of the active - /// chat, or a fork of it through its latest completed response. - fn render_chats_header_actions(&self, theme: &Theme, cx: &mut Context) -> AnyElement { - // Hidden until the header is hovered, like the sidebar's row menus: - // the caret is the resting state, the actions appear on approach. - div() - .flex_none() - .flex() - .flex_row() - .items_center() - .gap(px(2.0)) - .opacity(0.0) - .group_hover(HEADER_GROUP, |s| s.opacity(1.0)) - .child( - header_action( - "files-sections-new-chat", - icons::PLUS, - "New side chat", - theme, - ) - .on_click(cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::NewChildChat); - })), - ) - .child( - header_action( - "files-sections-fork", - icons::FORK, - "Fork this chat", - theme, - ) - .on_click(cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::ForkChat); - })), - ) - .into_any_element() - } - - #[allow(clippy::too_many_arguments)] - fn render_section( - &mut self, - section: Section, - count: usize, - wanted: f32, - height: f32, - actions: Option, - badge: Option, - body: AnyElement, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - let open = self.sections.is_open(section); - self.sections.begin_reveal(section, height); - // The collapsed header carries the count; open, the rows speak. - let label: SharedString = if open || count == 0 { - section.label().into() - } else { - format!("{} ({count})", section.label()).into() - }; - // Toggling animates between 0 and the height this section gets from - // the budget (the body scrolls inside it); a closed section reopens - // to what it wants within the whole budget. - let full = if open { - height - } else { - wanted.min(FOOTER_HEIGHT - chrome_height()).max(0.0) - }; - let header = div() - .id(SharedString::from(format!( - "files-section-{}", - section.key() - ))) - .group(HEADER_GROUP) - .role(gpui::Role::Button) - .aria_label(SharedString::from(format!( - "{} {}", - if open { "Collapse" } else { "Expand" }, - section.label() - ))) - .flex_none() - .flex() - .flex_row() - .items_center() - .gap(px(6.0)) - .h(px(SECTION_HEADER_HEIGHT)) - .pl(px(Theme::SPACE_SM)) - .pr(px(4.0)) - .rounded(px(6.0)) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - // Open → close runs from the painted body height to 0, and - // back up to what the budget allows. - let was_open = this.sections.is_open(section); - let (resting, target) = if was_open { (full, 0.0) } else { (0.0, full) }; - this.sections.toggle(section, resting, target); - cx.notify(); - })) - .child( - // The badge trails the title, not the caret: the title - // truncates first, the badge never does. - div() - .flex_1() - .min_w_0() - .flex() - .flex_row() - .items_center() - .gap(px(6.0)) - .child( - div() - .min_w_0() - .truncate() - .text_size(crate::typography::ui_rems(12.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.text_muted.opacity(0.5)) - .child(label), - ) - .children(badge), - ) - .children(actions) - .child(self.render_chevron(section, open, theme)); - div() - .flex_none() - .flex() - .flex_col() - .child(header) - .child(self.render_disclosure_body(section, open, height, body)) - .into_any_element() - } - - fn render_chevron(&self, section: Section, open: bool, theme: &Theme) -> AnyElement { - let chevron = icon(icons::ALT_ARROW_RIGHT) - .size(px(12.0)) - .text_color(theme.text_muted.opacity(0.5)); - let frame = div() - .flex_none() - .size(px(20.0)) - .flex() - .items_center() - .justify_center(); - if let Some(tween) = self.sections.live_motion(section) { - let denominator = tween.from.max(tween.to).max(1.0); - let from = (tween.from / denominator).clamp(0.0, 1.0); - let to = (tween.to / denominator).clamp(0.0, 1.0); - frame - .child(chevron.with_animation( - SharedString::from(format!( - "files-section-chevron-{}-{}", - section.key(), - tween.epoch - )), - collapse_animation(), - move |el, t| { - let reveal = motion::lerp(from, to, t); - el.with_transformation(gpui::Transformation::rotate(gpui::percentage( - reveal * 0.25, - ))) - }, - )) - .into_any_element() - } else { - let resting = if open { 0.25 } else { 0.0 }; - frame - .child( - chevron.with_transformation(gpui::Transformation::rotate(gpui::percentage( - resting, - ))), - ) - .into_any_element() - } - } - - fn render_disclosure_body( - &self, - section: Section, - open: bool, - height: f32, - content: AnyElement, - ) -> AnyElement { - let target = if open { height } else { 0.0 }; - let frame = div().w_full().flex_none().overflow_hidden().child(content); - let Some(tween) = self.sections.live_motion(section) else { - return frame.h(px(target)).into_any_element(); - }; - let denominator = tween.from.max(tween.to).max(1.0); - frame - .with_animation( - SharedString::from(format!( - "files-section-body-{}-{}", - section.key(), - tween.epoch - )), - collapse_animation(), - move |el, t| { - let height = motion::lerp(tween.from, tween.to, t); - let reveal = (height / denominator).clamp(0.0, 1.0); - el.h(px(height)) - .opacity(0.35 + 0.65 * reveal) - .relative() - .top(px(-3.0 * (1.0 - reveal))) - }, - ) - .into_any_element() - } - - fn render_show_more( - &self, - section: Section, - remaining: usize, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - show_more_row( - format!("files-section-{}-more", section.key()), - remaining, - 0.0, - theme, - ) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - let shown = this.sections.shown(section) + PAGE_ROWS; - this.sections.shown.insert(section, shown); - cx.notify(); - })) - .into_any_element() - } - - fn render_subagent_rows( - &self, - plan: &SubagentPlan, - view: EntityId, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - if plan.total() == 0 { - return empty_state( - "Subagents will appear here when they are created", - None, - theme, - ); - } - let now = Utc::now(); - let scroll = self.sections.scroll(Section::Subagents); - let mut list = row_list("files-subagent-rows", &scroll); - for row in &plan.active { - list = list.child(self.render_subagent_row(row, 0.0, now, view, theme, cx)); - } - if plan.finished() > 0 { - list = list.child(self.render_group_header( - Group::Finished, - plan.finished(), - 0.0, - theme, - cx, - )); - if self.sections.groups.is_open(Group::Finished) { - for group in [Group::Completed, Group::Failed] { - let rows = plan.rows(group); - if rows.is_empty() { - continue; - } - list = list.child(self.render_group_header( - group, - rows.len(), - GROUP_INDENT, - theme, - cx, - )); - if !self.sections.groups.is_open(group) { - continue; - } - let shown = self.sections.groups.shown(group); - for row in rows.iter().take(shown) { - list = list.child(self.render_subagent_row( - row, - 2.0 * GROUP_INDENT, - now, - view, - theme, - cx, - )); - } - if rows.len() > shown { - list = list.child(self.render_group_show_more( - group, - rows.len() - shown, - 2.0 * GROUP_INDENT, - theme, - cx, - )); - } - } - } - } - faded_list(list, &scroll) - } - - /// One subagent: status glyph, title, and the time it was last updated. - fn render_subagent_row( - &self, - row: &SubagentRow, - indent: f32, - now: DateTime, - view: EntityId, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - let glyph = status_glyph( - format!("files-subagent-{}", row.doc_id), - row.indicator(), - view, - theme, - cx, - ); - let open = row.clone(); - compact_row(format!("files-subagent-{}", row.doc_id), theme) - .pl(px(Theme::SPACE_SM + indent)) - .aria_label(SharedString::from(format!("Open subagent {}", row.title))) - .on_click(cx.listener(move |_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::OpenSubagent { - doc_id: open.doc_id.clone(), - title: open.title.to_string(), - frozen: open.frozen(), - }); - })) - .child(glyph) - .child(row_title( - format!("files-subagent-title-{}", row.doc_id), - row.title.clone(), - )) - .child(time_ago_label( - zeron_proto::view::format_time_ago(row.spawned_at, now).into(), - theme, - )) - .into_any_element() - } - - /// A dropdown header inside the Subagents body: caret and - /// "Label (count)". Clicking toggles the group. - fn render_group_header( - &self, - group: Group, - count: usize, - indent: f32, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - let open = self.sections.groups.is_open(group); - let chevron = icon(icons::ALT_ARROW_RIGHT) - .size(px(12.0)) - .text_color(theme.text_muted.opacity(0.5)) - .with_transformation(gpui::Transformation::rotate(gpui::percentage(if open { - 0.25 - } else { - 0.0 - }))); - div() - .id(SharedString::from(format!( - "files-subagent-group-{}", - group.key() - ))) - .role(gpui::Role::Button) - .aria_label(SharedString::from(format!( - "{} {} subagents", - if open { "Collapse" } else { "Expand" }, - group.label() - ))) - .flex_none() - .h(px(ROW_HEIGHT)) - .flex() - .flex_row() - .items_center() - .gap(px(2.0)) - .rounded(px(8.0)) - .pl(px(Theme::SPACE_SM + indent - 4.0)) - .pr(px(4.0)) - .cursor_pointer() - .hover(|s| s.bg(theme.glass_hover())) - .on_click(cx.listener(move |this, _, _, cx| { - this.sections.groups.toggle(group); - cx.notify(); - })) - .child( - div() - .flex_none() - .size(px(16.0)) - .flex() - .items_center() - .justify_center() - .child(chevron), - ) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(crate::typography::ui_rems(12.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.text_muted.opacity(0.7)) - .child(SharedString::from(format!("{} ({count})", group.label()))), - ) - .into_any_element() - } - - /// "Show N more" under one finished category, indented with its rows. - fn render_group_show_more( - &self, - group: Group, - remaining: usize, - indent: f32, - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - show_more_row( - format!("files-subagent-group-{}-more", group.key()), - remaining, - indent, - theme, - ) - .on_click(cx.listener(move |this, _, _, cx| { - cx.stop_propagation(); - this.sections.groups.page_up(group); - cx.notify(); - })) - .into_any_element() - } - - fn render_chat_rows( - &self, - rows: &[ChildChatRow], - theme: &Theme, - cx: &mut Context, - ) -> AnyElement { - if rows.is_empty() { - let actions = div() - .flex() - .flex_row() - .items_center() - .gap(px(6.0)) - .h(px(EMPTY_ACTIONS_HEIGHT)) - .child( - pill_button( - "files-sections-empty-fork", - icons::FORK, - "Fork", - theme, - ) - .on_click(cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::ForkChat); - })), - ) - .child( - pill_button( - "files-sections-empty-new", - icons::PLUS, - "New side chat", - theme, - ) - .on_click(cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::NewChildChat); - })), - ) - .into_any_element(); - return empty_state( - "Side chats will appear here when they are created", - Some(actions), - theme, - ); - } - let view = cx.entity_id(); - let shown = self.sections.shown(Section::Chats); - let scroll = self.sections.scroll(Section::Chats); - let mut list = row_list("files-chat-rows", &scroll); - for row in rows.iter().take(shown) { - let glyph = status_glyph( - format!("files-chat-{}", row.chat_id), - row.status, - view, - theme, - cx, - ); - let open_id = row.chat_id.clone(); - let menu_id = row.chat_id.clone(); - let rename_input = self - .sections - .chat_rename - .as_ref() - .filter(|(id, _)| *id == row.chat_id) - .map(|(_, input)| input.clone()); - let title = match rename_input { - Some(input) => crate::shell::chat_title_editor( - format!("files-chat-title-editor-{}", row.chat_id).into(), - input, - theme, - ), - None => row_title( - format!("files-chat-title-{}", row.chat_id), - row.title.clone(), - ) - .into_any_element(), - }; - list = list.child( - compact_row(format!("files-chat-{}", row.chat_id), theme) - .aria_label(SharedString::from(format!("Open side chat {}", row.title))) - .on_click(cx.listener(move |_, event: &gpui::ClickEvent, _, cx| { - cx.stop_propagation(); - // The first click opened the tab; the second edits - // the title in place. - if event.click_count() >= 2 { - cx.emit(FilesEvent::RenameChildChat(open_id.clone())); - } else { - cx.emit(FilesEvent::OpenChildChat(open_id.clone())); - } - })) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |_, event: &gpui::MouseDownEvent, _, cx| { - cx.stop_propagation(); - cx.emit(FilesEvent::ChildChatContextMenu { - chat_id: menu_id.clone(), - position: event.position, - }); - }), - ) - .child(glyph) - .child(title) - .children(row.change_request.clone().map(|summary| { - crate::change_requests::pull_request_badge( - format!("files-chat-pr-{}", row.chat_id).into(), - summary, - crate::change_requests::ChangeRequestBadgeSurface::Sidebar, - Some(row.device_id.clone()), - theme, - ) - })) - .child(time_ago_label(row.time_ago.clone(), theme)), - ); - } - if rows.len() > shown { - list = list.child(self.render_show_more(Section::Chats, rows.len() - shown, theme, cx)); - } - faded_list(list, &scroll) - } -} - -fn collapse_animation() -> Animation { - motion::COLLAPSE.animation() -} - -/// A 20px icon button in a section header, sized to sit beside the caret. -fn header_action( - id: &'static str, - icon_path: &'static str, - label: &'static str, - theme: &Theme, -) -> gpui::Stateful { - div() - .id(id) - .role(gpui::Role::Button) - .aria_label(label) - .flex_none() - .size(px(20.0)) - .rounded(px(5.0)) - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(crate::theme::wash(0.09))) - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .tooltip(crate::settings::widgets::text_tooltip(label)) - // Icons take their color on the element itself, never inherited. - .child( - icon(icon_path) - .size(px(13.0)) - .text_color(theme.text_muted.opacity(0.85)), - ) -} - -/// A section's "Show N more" row (a page is [`PAGE_ROWS`]), indented to line -/// up with the rows it pages. -fn show_more_row( - id: String, - remaining: usize, - indent: f32, - theme: &Theme, -) -> gpui::Stateful { - div() - .id(SharedString::from(id)) - .role(gpui::Role::Button) - .flex_none() - .h(px(ROW_HEIGHT)) - .flex() - .items_center() - .pl(px(Theme::SPACE_SM + indent)) - .pr(px(Theme::SPACE_SM)) - .rounded(px(8.0)) - .cursor_pointer() - .text_size(crate::typography::ui_rems(12.0)) - .text_color(theme.text_muted.opacity(0.7)) - .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text_muted)) - .child(format!("Show {} more", remaining.min(PAGE_ROWS))) -} - -/// The empty state's pill buttons — the explorer's Retry button shape. -fn pill_button( - id: &'static str, - icon_path: &'static str, - label: &'static str, - theme: &Theme, -) -> gpui::Stateful { - div() - .id(id) - .role(gpui::Role::Button) - .aria_label(label) - .h(px(26.0)) - .px(px(10.0)) - .rounded(px(7.0)) - .border_1() - .border_color(theme.border) - .bg(crate::theme::wash(0.04)) - .hover(|style| style.bg(crate::theme::wash(0.09))) - .cursor_pointer() - .flex() - .flex_row() - .items_center() - .gap(px(5.0)) - .text_size(px(11.5)) - .text_color(theme.text) - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .child(icon(icon_path).size(px(12.0)).text_color(theme.text_muted)) - .child(label) -} - -/// The scrolling column an open section's rows live in; the body frame -/// sets the height, the list fills it and reports its overflow through -/// `scroll` for the edge fades. -fn row_list(id: &'static str, scroll: &ScrollHandle) -> gpui::Stateful { - div() - .id(id) - .size_full() - .flex() - .flex_col() - .gap(px(ROW_GAP)) - .pt(px(SECTION_BODY_INSET)) - .overflow_y_scroll() - .track_scroll(scroll) -} - -/// A section list under the sidebar's overflow fades: rows dissolve at the -/// edges while there is more to scroll to, plain when everything fits. -fn faded_list(list: gpui::Stateful, scroll: &ScrollHandle) -> AnyElement { - crate::edge_fade::edge_faded( - LIST_FADE_BAND, - true, - true, - div().relative().size_full().child(list), - ) - .fade_overflow_y(scroll) - .into_any_element() -} - -/// An empty section: its copy where the first row would sit, and any -/// actions on a row below it. -fn empty_state(copy: &'static str, actions: Option, theme: &Theme) -> AnyElement { - div() - .pt(px(SECTION_BODY_INSET + EMPTY_PAD)) - .pb(px(EMPTY_PAD)) - .px(px(Theme::SPACE_SM)) - .flex() - .flex_col() - .child( - div() - .min_h(px(EMPTY_COPY_HEIGHT)) - .max_h(px(EMPTY_COPY_HEIGHT)) - .overflow_hidden() - .py(px(2.0)) - .text_size(crate::typography::ui_rems(12.0)) - .line_height(px(16.0)) - .text_color(theme.text_muted.opacity(0.5)) - .child(copy), - ) - .children(actions) - .into_any_element() -} - -/// The sidebar's compact session row, stripped to status + title (+ time): -/// 29px, 8px radius, the glass hover wash, 13px title on a 17px line. -fn compact_row(id: String, theme: &Theme) -> gpui::Stateful { - div() - .id(SharedString::from(id)) - .role(gpui::Role::Button) - .flex_none() - .h(px(ROW_HEIGHT)) - .flex() - .flex_row() - .items_center() - .gap(px(4.0)) - .rounded(px(8.0)) - .px(px(Theme::SPACE_SM)) - .cursor_pointer() - .text_color(theme.text.opacity(0.8)) - .hover(|s| s.bg(theme.glass_hover()).text_color(theme.text)) -} - -/// The compact row's trailing time, 11px in the faded subline color. -fn time_ago_label(text: SharedString, theme: &Theme) -> gpui::Div { - div() - .flex_none() - .text_size(crate::typography::ui_rems(11.0)) - .text_color(theme.text_muted.opacity(0.5)) - .child(text) -} - -/// The sidebar's fading label: overflow dissolves at the right edge instead -/// of an ellipsis. -fn row_title(id: String, title: SharedString) -> impl IntoElement { - crate::shell::sidebar_faded_label( - id.into(), - true, - div() - .text_size(crate::typography::ui_rems(13.0)) - .line_height(px(17.0)) - .child(title), - ) -} - -/// The compact row's 13px status slot: Working animates the glyph spinner, -/// Completed wears the check, the rest a 6px dot in the status color. -fn status_glyph( - key: String, - status: ChatIndicator, - view: EntityId, - theme: &Theme, - cx: &mut gpui::App, -) -> AnyElement { - let color = crate::shell::spaces::status_dot_color(status, theme); - let glyph: AnyElement = match status { - ChatIndicator::Completed => icon(icons::CHECK) - .size(px(11.0)) - .flex_none() - .text_color(color) - .into_any_element(), - ChatIndicator::Working => { - loaders::mini_glyph_spinner(format!("{key}-working"), 2.0, theme.glyph, view, cx) - .into_any_element() - } - _ => div() - .size(px(6.0)) - .flex_none() - .rounded_full() - .bg(color) - .into_any_element(), - }; - div() - .flex_none() - .size(px(13.0)) - .flex() - .items_center() - .justify_center() - .child(glyph) - .into_any_element() -} - -#[cfg(test)] -mod tests { - use super::*; - use zeron_doc::{MessageRole, MessageStatus, SessionMessageEntry}; - use zeron_proto::ToolCall; - - fn chat(id: &str, parent: Option<&str>, minutes_ago: i64) -> Chat { - serde_json::from_value(serde_json::json!({ - "id": id, - "deviceId": "dev", - "archived": false, - "createdAt": Utc::now() - chrono::Duration::minutes(minutes_ago), - "parentChatId": parent, - })) - .unwrap() - } - - fn spawn( - id: &str, - name: &str, - doc: Option<&str>, - status: Option, - ) -> MessagePart { - MessagePart::Tool { - id: id.into(), - call: ToolCall::Unknown { - name: name.into(), - input: Some(serde_json::json!({ "description": "verify the marker pipeline" })), - }, - is_error: false, - resolved: true, - output: None, - diff: None, - output_ref: None, - output_bytes: None, - diff_ref: None, - diff_stats: None, - subagent_ref: doc.map(str::to_owned), - subagent_status: status, - subagent_tail: None, - } - } - - fn entry(parts: Vec) -> SessionMessageEntry { - SessionMessageEntry { - duration_ms: None, - id: "e1".into(), - role: MessageRole::Assistant, - parts, - created_at: (Utc::now() - chrono::Duration::minutes(3)).timestamp_millis(), - device_id: "dev".into(), - status: Some(MessageStatus::Complete), - continuation_of: None, - } - } - - #[test] - fn subagent_rows_list_only_stamped_spawn_chips_of_the_selected_chat() { - let mut state = AppState::new(); - state.selected_chat = Some("main".into()); - state.transcript = vec![entry(vec![ - spawn( - "t1", - "Agent: verify", - Some("main--sub--t1"), - Some(SubagentStatus::Running), - ), - // No doc ref yet: the engine stamps it asynchronously. - spawn("t2", "Agent: later", None, None), - // A stray ref on a non-spawn tool never surfaces. - spawn( - "t3", - "Read", - Some("main--sub--t3"), - Some(SubagentStatus::Done), - ), - spawn( - "t4", - "Agent: done", - Some("main--sub--t4"), - Some(SubagentStatus::Done), - ), - ])]; - let rows = subagent_rows(&state, "main"); - assert_eq!( - rows.iter().map(|r| r.doc_id.as_str()).collect::>(), - ["main--sub--t1", "main--sub--t4"] - ); - // The bare task, genus stripped — the same title the tab wears. - assert_eq!(rows[0].title.as_ref(), "verify"); - assert!(rows[1].frozen()); - assert!(!rows[0].frozen()); - // Spawn time comes from the turn that carried the chip. - assert!((Utc::now() - rows[0].spawned_at).num_minutes() >= 2); - // Another chat's explorer sees nothing of this transcript. - assert!(subagent_rows(&state, "other").is_empty()); - } - - #[test] - fn subagent_rows_are_most_recently_updated_first() { - let at = |id: &str, minutes_ago: i64, parts| SessionMessageEntry { - id: id.into(), - created_at: (Utc::now() - chrono::Duration::minutes(minutes_ago)).timestamp_millis(), - ..entry(parts) - }; - let mut state = AppState::new(); - state.selected_chat = Some("main".into()); - state.transcript = vec![ - at( - "e1", - 30, - vec![ - spawn( - "a", - "Agent: a", - Some("main--sub--a"), - Some(SubagentStatus::Done), - ), - spawn( - "b", - "Agent: b", - Some("main--sub--b"), - Some(SubagentStatus::Done), - ), - ], - ), - at( - "e2", - 20, - vec![spawn( - "c", - "Agent: c", - Some("main--sub--c"), - Some(SubagentStatus::Done), - )], - ), - // `a` is steered again: its row moves up with the newer turn. - at( - "e3", - 10, - vec![spawn( - "a", - "Agent: a", - Some("main--sub--a"), - Some(SubagentStatus::Running), - )], - ), - ]; - let rows = subagent_rows(&state, "main"); - assert_eq!( - rows.iter().map(|r| r.doc_id.as_str()).collect::>(), - ["main--sub--a", "main--sub--c", "main--sub--b"] - ); - assert_eq!(rows[0].status, Some(SubagentStatus::Running)); - } - - #[test] - fn running_subagents_lead_longest_running_first() { - let at = |id: &str, minutes_ago: i64, parts| SessionMessageEntry { - id: id.into(), - created_at: (Utc::now() - chrono::Duration::minutes(minutes_ago)).timestamp_millis(), - ..entry(parts) - }; - let sub = |id: &str, status| { - spawn( - id, - &format!("Agent: {id}"), - Some(&format!("main--sub--{id}")), - Some(status), - ) - }; - let mut state = AppState::new(); - state.selected_chat = Some("main".into()); - state.transcript = vec![ - at("e1", 90, vec![sub("old-run", SubagentStatus::Running)]), - at("e2", 60, vec![sub("old-done", SubagentStatus::Done)]), - at("e3", 30, vec![sub("mid-run", SubagentStatus::Running)]), - at("e4", 20, vec![sub("mid-fail", SubagentStatus::Failed)]), - at("e5", 10, vec![sub("new-done", SubagentStatus::Done)]), - at("e6", 5, vec![sub("new-run", SubagentStatus::Running)]), - ]; - let ids: Vec<_> = subagent_rows(&state, "main") - .into_iter() - .map(|r| r.doc_id) - .collect(); - // Running first, longest-running leading; the settled tail keeps the - // newest-first order it always had. - assert_eq!( - ids, - [ - "main--sub--old-run", - "main--sub--mid-run", - "main--sub--new-run", - "main--sub--new-done", - "main--sub--mid-fail", - "main--sub--old-done", - ] - ); - } - - #[test] - fn child_chat_rows_are_live_children_newest_first() { - let mut state = AppState::new(); - let mut archived = chat("old", Some("main"), 1); - archived.archived = true; - let mut titled = chat("b", Some("main"), 30); - titled.title = Some("Investigate caching".into()); - state.apply_chats(vec![ - chat("main", None, 60), - chat("a", Some("main"), 5), - titled, - chat("unrelated", Some("elsewhere"), 2), - archived, - ]); - let rows = child_chat_rows(&state, "main", Utc::now()); - assert_eq!( - rows.iter().map(|r| r.chat_id.as_str()).collect::>(), - ["a", "b"] - ); - assert_eq!(rows[0].title.as_ref(), "New side chat"); - assert_eq!(rows[1].title.as_ref(), "Investigate caching"); - assert_eq!(rows[0].status, ChatIndicator::Idle); - } - - #[gpui::test] - fn double_clicked_side_chat_asks_for_an_inline_rename(cx: &mut gpui::TestAppContext) { - use gpui::{AppContext as _, MouseDownEvent, MouseUpEvent}; - - let (files, cx) = super::super::test_support::setup(cx); - files.update(cx, |files, cx| { - files.state.update(cx, |state, _| { - let mut side = chat("side", Some("chat"), 1); - side.title = Some("Side work".into()); - state.chats.push(side); - }); - }); - cx.update(|window, cx| { - window.refresh(); - window.draw(cx).clear(); - }); - let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - let recorded = events.clone(); - let _sub = cx.update(|_, cx| { - cx.subscribe(&files, move |_, event, _| { - recorded.borrow_mut().push(event.clone()) - }) - }); - let row = cx.debug_bounds("files-chat-title-side").unwrap(); - for click_count in [1, 2] { - cx.simulate_event(MouseDownEvent { - button: MouseButton::Left, - position: row.center(), - click_count, - ..Default::default() - }); - cx.simulate_event(MouseUpEvent { - button: MouseButton::Left, - position: row.center(), - click_count, - ..Default::default() - }); - } - cx.run_until_parked(); - { - let events = events.borrow(); - assert!(matches!(&events[..], [ - FilesEvent::OpenChildChat(open), - FilesEvent::RenameChildChat(rename), - ] if open == "side" && rename == "side")); - } - - // The shell's field replaces the row title while the edit is open. - let input = cx.update(|_, cx| cx.new(|cx| ComposerInput::new("Session title", cx))); - files.update(cx, |files, cx| { - files.set_chat_rename(Some(("side".into(), input)), cx) - }); - cx.update(|window, cx| window.draw(cx).clear()); - assert!(cx.debug_bounds("files-chat-title-editor-side").is_some()); - assert!(cx.debug_bounds("files-chat-title-side").is_none()); - files.update(cx, |files, cx| files.set_chat_rename(None, cx)); - cx.update(|window, cx| window.draw(cx).clear()); - assert!(cx.debug_bounds("files-chat-title-side").is_some()); - } - - #[gpui::test] - fn inline_rename_reveals_a_side_chat_in_a_collapsed_paged_section( - cx: &mut gpui::TestAppContext, - ) { - use gpui::AppContext as _; - - let (files, cx) = super::super::test_support::setup(cx); - files.update(cx, |files, cx| { - files.state.update(cx, |state, _| { - for ix in 0..12 { - state - .chats - .push(chat(&format!("side-{ix:02}"), Some("chat"), ix)); - } - }); - files.sections.toggle(Section::Chats, 0.0, 0.0); - assert!(!files.sections.is_open(Section::Chats)); - }); - cx.update(|window, cx| { - window.refresh(); - window.draw(cx).clear(); - }); - - // Newest first: side-11 is the twelfth row, past the first page. - let input = cx.update(|_, cx| cx.new(|cx| ComposerInput::new("Session title", cx))); - files.update(cx, |files, cx| { - files.set_chat_rename(Some(("side-11".into(), input)), cx) - }); - cx.update(|window, cx| window.draw(cx).clear()); - files.read_with(cx, |files, _| { - assert!(files.sections.is_open(Section::Chats)); - assert_eq!( - files.sections.shown(Section::Chats), - INITIAL_ROWS + PAGE_ROWS - ); - // The reveal became the section's opening motion. - assert_eq!(files.sections.reveal, None); - assert_eq!(files.sections.motion[&Section::Chats].from, 0.0); - }); - assert!(cx.debug_bounds("files-chat-title-editor-side-11").is_some()); - } - - #[test] - fn fingerprint_tracks_membership_and_status() { - let mut state = AppState::new(); - let now = Utc::now(); - state.apply_chats(vec![chat("main", None, 60)]); - let empty = fingerprint(&state, "main", now); - state.apply_chats(vec![chat("main", None, 60), chat("a", Some("main"), 5)]); - let one = fingerprint(&state, "main", now); - assert_ne!(empty, one); - assert_eq!(one, fingerprint(&state, "main", now)); - } - - #[test] - fn content_height_pages_at_ten_rows_and_counts_the_show_more_row() { - // Short lists are floored so a section keeps its presence. - let one = content_height(Section::Chats, 1, INITIAL_ROWS); - assert_eq!(one, MIN_BODY_HEIGHT); - let ten = content_height(Section::Chats, 10, INITIAL_ROWS); - // Eleven rows: ten visible plus the "Show more" slot. - let eleven = content_height(Section::Chats, 11, INITIAL_ROWS); - assert_eq!(eleven - ten, ROW_HEIGHT + ROW_GAP); - // Paging once reveals up to 20 rows before the next "Show more". - let paged = content_height(Section::Chats, 40, INITIAL_ROWS + PAGE_ROWS); - assert_eq!( - paged, - SECTION_BODY_INSET + 21.0 * ROW_HEIGHT + 20.0 * ROW_GAP - ); - // Empty sections want their icon + copy; Chats adds the action row. - assert_eq!( - content_height_unfloored(Section::Chats, 0, INITIAL_ROWS) - - content_height_unfloored(Section::Subagents, 0, INITIAL_ROWS), - EMPTY_ACTIONS_HEIGHT - ); - assert!(content_height(Section::Subagents, 0, INITIAL_ROWS) >= MIN_BODY_HEIGHT); - } - - #[test] - fn body_budget_shares_the_footer_and_hands_slack_across() { - // Both fit: each takes what it wants. - assert_eq!( - body_budget(300.0, [100.0, 100.0], [true, true]), - [100.0, 100.0] - ); - // Closed sections take nothing. - assert_eq!( - body_budget(300.0, [100.0, 100.0], [false, true]), - [0.0, 100.0] - ); - // Both oversubscribed: an even split. - assert_eq!( - body_budget(200.0, [500.0, 500.0], [true, true]), - [100.0, 100.0] - ); - // A short second section hands its slack to the first. - assert_eq!( - body_budget(200.0, [500.0, 40.0], [true, true]), - [160.0, 40.0] - ); - // A short first section hands its slack to the second. - assert_eq!( - body_budget(200.0, [40.0, 500.0], [true, true]), - [40.0, 160.0] - ); - assert_eq!(body_budget(-5.0, [40.0, 500.0], [true, true]), [0.0, 0.0]); - } -} diff --git a/crates/ui/src/files/subagents.rs b/crates/ui/src/files/subagents.rs deleted file mode 100644 index a7beec720b..0000000000 --- a/crates/ui/src/files/subagents.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! The Subagents section's model, kept pure so its shape and height are -//! testable without a window. -//! -//! Running subagents lead the body as plain rows. Everything that has -//! settled folds into one **Finished** dropdown, closed until opened, that -//! splits into **Completed** and **Failed** lists. Rows are read from the -//! transcript's spawn chips, so nothing here removes or hides one. - -use std::collections::{HashMap, HashSet}; - -use zeron_doc::SubagentStatus; - -use super::sections::{INITIAL_ROWS, PAGE_ROWS, SubagentRow}; - -/// A collapsible group of finished subagents. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(super) enum Group { - /// Parent of the two categories below. - Finished, - Completed, - Failed, -} - -impl Group { - pub fn label(self) -> &'static str { - match self { - Group::Finished => "Finished", - Group::Completed => "Completed", - Group::Failed => "Failed", - } - } - - pub fn key(self) -> &'static str { - match self { - Group::Finished => "finished", - Group::Completed => "completed", - Group::Failed => "failed", - } - } -} - -/// Open/closed and paging state of the groups. The Finished dropdown starts -/// closed, so the body opens on what is running; the two lists inside it start -/// open, so one click shows every finished subagent. -#[derive(Debug, Default)] -pub(super) struct GroupState { - /// Groups flipped away from their default. - flipped: HashSet, - /// Rows revealed per category ("Show more" pages this up). - shown: HashMap, -} - -impl GroupState { - pub fn is_open(&self, group: Group) -> bool { - let default_open = group != Group::Finished; - default_open != self.flipped.contains(&group) - } - - pub fn toggle(&mut self, group: Group) { - if !self.flipped.remove(&group) { - self.flipped.insert(group); - } - } - - pub fn shown(&self, group: Group) -> usize { - self.shown - .get(&group) - .copied() - .unwrap_or(INITIAL_ROWS) - .max(INITIAL_ROWS) - } - - pub fn page_up(&mut self, group: Group) { - let shown = self.shown(group) + PAGE_ROWS; - self.shown.insert(group, shown); - } -} - -/// The rows the Subagents body draws, in the order it draws them. -#[derive(Debug, Default, PartialEq, Eq)] -pub(super) struct SubagentPlan { - /// Not settled: running first (longest-running leading), then any row - /// whose status has not been stamped yet. - pub active: Vec, - pub completed: Vec, - pub failed: Vec, -} - -impl SubagentPlan { - /// Sort `rows` (already in display order) into the three lists. - pub fn new(rows: Vec) -> Self { - let mut plan = Self::default(); - for row in rows { - match row.status { - Some(SubagentStatus::Done) => plan.completed.push(row), - Some(SubagentStatus::Failed) => plan.failed.push(row), - _ => plan.active.push(row), - } - } - plan - } - - /// Subagents streaming right now. - pub fn running(&self) -> usize { - self.active - .iter() - .filter(|row| row.status == Some(SubagentStatus::Running)) - .count() - } - - pub fn finished(&self) -> usize { - self.completed.len() + self.failed.len() - } - - /// Everything the list would show with all groups open. - pub fn total(&self) -> usize { - self.active.len() + self.finished() - } - - /// The rows behind a group; `Finished` is both categories, completed first. - pub fn rows(&self, group: Group) -> Vec<&SubagentRow> { - match group { - Group::Finished => self.completed.iter().chain(&self.failed).collect(), - Group::Completed => self.completed.iter().collect(), - Group::Failed => self.failed.iter().collect(), - } - } - - /// Row-sized slots the body occupies: each visible row, each group - /// header, and a "Show more" row wherever a category is paged. - pub fn slots(&self, groups: &GroupState) -> usize { - let mut slots = self.active.len(); - if self.finished() == 0 { - return slots; - } - slots += 1; - if !groups.is_open(Group::Finished) { - return slots; - } - for group in [Group::Completed, Group::Failed] { - let count = self.rows(group).len(); - if count == 0 { - continue; - } - slots += 1; - if groups.is_open(group) { - let shown = groups.shown(group); - slots += count.min(shown) + usize::from(count > shown); - } - } - slots - } -} - -#[cfg(test)] -mod tests { - use chrono::Utc; - - use super::*; - - fn row(id: &str, status: Option) -> SubagentRow { - SubagentRow { - doc_id: id.into(), - title: id.to_owned().into(), - status, - spawned_at: Utc::now(), - } - } - - fn mixed() -> Vec { - vec![ - row("run", Some(SubagentStatus::Running)), - row("new", None), - row("ok-1", Some(SubagentStatus::Done)), - row("bad", Some(SubagentStatus::Failed)), - row("ok-2", Some(SubagentStatus::Done)), - ] - } - - fn ids(rows: &[SubagentRow]) -> Vec<&str> { - rows.iter().map(|r| r.doc_id.as_str()).collect() - } - - /// Groups as the section first draws them, with Finished opened. - fn opened() -> GroupState { - let mut groups = GroupState::default(); - groups.toggle(Group::Finished); - groups - } - - #[test] - fn settled_rows_fold_into_lists_and_the_rest_stay_on_top() { - let plan = SubagentPlan::new(mixed()); - assert_eq!(ids(&plan.active), ["run", "new"]); - assert_eq!(ids(&plan.completed), ["ok-1", "ok-2"]); - assert_eq!(ids(&plan.failed), ["bad"]); - assert_eq!((plan.running(), plan.finished(), plan.total()), (1, 3, 5)); - } - - #[test] - fn finished_starts_closed_and_its_lists_start_open() { - let groups = GroupState::default(); - assert!(!groups.is_open(Group::Finished)); - assert!(groups.is_open(Group::Completed)); - assert!(groups.is_open(Group::Failed)); - } - - #[test] - fn slots_follow_what_is_open() { - let plan = SubagentPlan::new(mixed()); - let mut groups = GroupState::default(); - // Closed: 2 active + the Finished header. - assert_eq!(plan.slots(&groups), 2 + 1); - groups.toggle(Group::Finished); - // + (Completed header + 2) + (Failed header + 1) - assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 2); - groups.toggle(Group::Failed); - assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 1); - // Closing the parent keeps each list's own state for the next open. - groups.toggle(Group::Finished); - assert_eq!(plan.slots(&groups), 2 + 1); - groups.toggle(Group::Finished); - assert_eq!(plan.slots(&groups), 2 + 1 + 3 + 1); - } - - #[test] - fn nothing_finished_means_no_group_chrome() { - let rows = vec![row("run", Some(SubagentStatus::Running))]; - let plan = SubagentPlan::new(rows); - assert_eq!(plan.slots(&opened()), 1); - assert_eq!(SubagentPlan::default().slots(&opened()), 0); - } - - #[test] - fn a_long_list_pages_and_counts_its_show_more_row() { - let rows: Vec<_> = (0..25) - .map(|i| row(&format!("ok-{i}"), Some(SubagentStatus::Done))) - .collect(); - let plan = SubagentPlan::new(rows); - let mut groups = opened(); - // Finished header + Completed header + 10 rows + "Show more". - assert_eq!(plan.slots(&groups), 1 + 1 + INITIAL_ROWS + 1); - groups.page_up(Group::Completed); - assert_eq!(plan.slots(&groups), 1 + 1 + INITIAL_ROWS + PAGE_ROWS + 1); - groups.page_up(Group::Completed); - // All 25 shown: no "Show more" left. - assert_eq!(plan.slots(&groups), 1 + 1 + 25); - } -} diff --git a/crates/ui/src/running_pill.rs b/crates/ui/src/running_pill.rs index 46e7fa670d..3b3114a8b9 100644 --- a/crates/ui/src/running_pill.rs +++ b/crates/ui/src/running_pill.rs @@ -9,7 +9,6 @@ use gpui::{ prelude::*, px, size, }; -use crate::loaders; use crate::motion::{self, ZERON_PULSE}; use crate::theme::Theme; @@ -30,8 +29,6 @@ const PLUS_TRIM: f32 = 1.0; const DIGIT_MID: f32 = 0.355; /// Height of the count badge on the titlebar button. const BADGE: f32 = 13.0; -/// Corner radius of the titlebar's icon buttons. -const BUTTON_RADIUS: f32 = 6.0; /// Past this the count would outgrow the pill; it reads "99+". const COUNT_CAP: u32 = 99; @@ -249,11 +246,12 @@ fn paint_pill( let _ = line.paint(text_origin, height, TextAlign::Left, None, window, cx); } -/// Marks the titlebar's explorer button while subagents run, so they can be -/// found with the panel closed: the button's face breathes in the activity -/// tone, and a small count badge sits on its corner. Both are drawn over the -/// button, so it keeps its size and position among the titlebar controls. -pub fn mark_files_button( +/// Marks a titlebar button while subagents run behind it, so they can be +/// found with its card or panel closed: a small count badge in the activity +/// tone sits on its corner, drawn over the button so it keeps its size and +/// position among the titlebar controls. It holds still; the count is the +/// signal. +pub fn mark_titlebar_button( button: gpui::Stateful, count: u32, theme: &Theme, @@ -261,13 +259,6 @@ pub fn mark_files_button( let tone = theme.busy; button .relative() - .child(div().absolute().inset_0().child(loaders::pulse_glow( - "files-button-glow", - BUTTON_RADIUS, - tone, - 0.10, - 0.30, - ))) .child( div() .absolute() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index e95d06199d..78e4978cd4 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -82,6 +82,7 @@ use panel_layout::{AuxiliaryPanel, FitInputs, HorizontalPanelFit, panel_content_ mod panel_interaction_tests; pub(crate) mod project_icon; mod session_info; +mod session_lanes; mod side_chats; mod sidebar_pins; mod sidebar_sections; @@ -1454,11 +1455,11 @@ enum SplashPhase { const CHAT_RENAME_REVEAL_GRACE: std::time::Duration = std::time::Duration::from_millis(150); /// Which list draws a chat's inline title editor: the sidebar's session row, -/// or the side-chat row in the explorer's Chats footer. +/// or the side-chat branch in the session card's lanes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChatRenameSurface { Sidebar, - Explorer, + SessionCard, } /// A chat title edited in place on its row (double-click, or Rename in the @@ -2222,6 +2223,8 @@ pub struct Shell { /// The room the transcript last held free for the docked card, eased /// back from as the card hides because the column stopped fitting it. session_info_last_reserve: f32, + /// The lanes card's folds and pages. + session_lanes: session_lanes::LaneState, files_content_tween: Option, /// The last tracked frame: its chat, the window fit, and for each of /// `[sidebar, surface host, Files]` the width painted and the width its @@ -2669,6 +2672,7 @@ impl Shell { session_info_pressed_open: None, session_info_docked: false, session_info_last_reserve: 0.0, + session_lanes: session_lanes::LaneState::default(), files_content_tween: None, painted_columns: None, fit_exits: [None; 3], @@ -4023,14 +4027,6 @@ impl Shell { FilesEvent::CloseReady => { this.on_file_close_ready(RightSurface::File(id), &event_panel_key, cx) } - // Footer rows exist on the explorer only; an editor - // surface never emits them. - FilesEvent::OpenSubagent { .. } - | FilesEvent::OpenChildChat(_) - | FilesEvent::RenameChildChat(_) - | FilesEvent::ChildChatContextMenu { .. } - | FilesEvent::NewChildChat - | FilesEvent::ForkChat => {} FilesEvent::CloseCancelled => { this.cancel_file_close(RightSurface::File(id), cx) } @@ -5762,7 +5758,7 @@ impl Shell { } /// Start editing `chat_id`'s title in place on its row: the sidebar's - /// session row, or — for a side chat — its row under the file tree. + /// session row, or — for a side chat — its branch in the session card. fn open_rename_chat(&mut self, chat_id: String, cx: &mut Context) { self.close_chat_menu(cx); if self.chat_rename.is_some() { @@ -5784,28 +5780,27 @@ impl Shell { else { return; }; - // The sidebar lists only top-level chats; side chats live in the - // explorer's Chats footer. Either way the row must be on screen - // first: a field on a hidden row would take keystrokes unseen. + // The sidebar lists only top-level chats; side chats ride the + // session card's lanes. Either way the row must be on screen first: + // a field on a hidden row would take keystrokes unseen. let surface = if parent.is_some() { - ChatRenameSurface::Explorer + ChatRenameSurface::SessionCard } else { ChatRenameSurface::Sidebar }; let revealed = match surface { ChatRenameSurface::Sidebar => self.reveal_sidebar_chat(&chat_id, cx), - // The footer lists the open chat's live side chats; it reveals - // the row itself once handed the rename. - ChatRenameSurface::Explorer => { + // The lanes list the open chat's live side chats: show the card + // and page its lane to the branch. + ChatRenameSurface::SessionCard => { !archived && parent.as_deref() == Some(self.active_chat.as_str()) - && self.files_panel_open(cx) - && self.files.contains_key(&self.panel_key(cx)) + && self.reveal_session_lane_chat(&chat_id, cx) } }; if !revealed { // The sidebar explains its own refusals. - if surface == ChatRenameSurface::Explorer { + if surface == ChatRenameSurface::SessionCard { self.show_notice(crate::toast::ToastKind::Error, "Open this side chat's parent to rename it", cx); } cx.notify(); @@ -5845,7 +5840,6 @@ impl Shell { _events: events, _blur: None, }); - self.sync_explorer_chat_rename(cx); cx.notify(); } @@ -5870,7 +5864,6 @@ impl Shell { let Some(rename) = self.chat_rename.take() else { return; }; - self.sync_explorer_chat_rename(cx); let title = rename.input.read(cx).text().trim().to_string(); let unchanged = self .state @@ -5888,18 +5881,6 @@ impl Shell { cx.notify(); } - /// Hand the explorer footers the side-chat rename to draw (or clear it). - fn sync_explorer_chat_rename(&mut self, cx: &mut Context) { - let rename = self - .chat_rename - .as_ref() - .filter(|rename| rename.surface == ChatRenameSurface::Explorer) - .map(|rename| (rename.chat_id.clone(), rename.input.clone())); - for files in self.files.values() { - files.update(cx, |files, cx| files.set_chat_rename(rename.clone(), cx)); - } - } - /// Scroll the sidebar so the renamed row (painted at `row`) clears the /// list's edge fades, while the rename is still revealing it. fn keep_rename_row_in_view(&mut self, row: gpui::Bounds, cx: &mut Context) { diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 67786b3435..2e708df349 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -327,34 +327,6 @@ impl Shell { FilesEvent::ShowAllFilesChanged(show_all) => { this.set_files_show_all(*show_all, cx) } - // Footer rows land in the surface host beside the explorer, - // through the same paths a spawn chip and a side-chat tab use. - FilesEvent::OpenSubagent { - doc_id, - title, - frozen, - } => this.add_subagent_surface( - this.active_chat.clone(), - doc_id.clone(), - title.clone(), - *frozen, - cx, - ), - FilesEvent::OpenChildChat(chat_id) => this.open_child_chat_tab(chat_id, cx), - FilesEvent::RenameChildChat(chat_id) => { - this.open_rename_chat(chat_id.clone(), cx) - } - FilesEvent::ChildChatContextMenu { chat_id, position } => { - this.chat_menu.open(ChatMenuState { - chat_id: chat_id.clone(), - tab: None, - position: *position, - page: ChatMenuPage::Root, - }); - cx.notify(); - } - FilesEvent::NewChildChat => this.create_child_chat(None, cx), - FilesEvent::ForkChat => this.create_side_chat(cx), _ => cx.notify(), }, ); diff --git a/crates/ui/src/shell/session_info.rs b/crates/ui/src/shell/session_info.rs index ecc0b51c51..e1d175f22c 100644 --- a/crates/ui/src/shell/session_info.rs +++ b/crates/ui/src/shell/session_info.rs @@ -1,5 +1,6 @@ -//! The session card: where this chat runs (device, project, branch), its -//! side-chat actions, and the project's run actions, in one toggleable card. +//! The session card: where this chat runs (device, project, branch) and the +//! project's run actions, with the lanes card (its side chats and subagents, +//! see `session_lanes`) stacked under it, both behind one toggle. //! While the column has room, the card shows at its right on its own (until //! the user hides it), the transcript and composer shifting left as needed //! to clear it. Once clearing it would narrow the transcript, the card hides, @@ -13,6 +14,8 @@ use crate::project_actions::{ProjectActionsStatus, action_icon}; pub(super) const SESSION_CARD_WIDTH: f32 = 272.0; const SESSION_CARD_INSET: f32 = 12.0; const SESSION_CARD_GAP: f32 = 24.0; +/// The gap between the session card and the lanes card under it. +const SESSION_CARDS_GAP: f32 = 8.0; /// The transcript's own side gutters (its rows pad 48px each side). const TRANSCRIPT_GUTTER: f32 = 48.0; @@ -101,9 +104,20 @@ impl Shell { cx.notify(); } + /// Show the card: beside the transcript when it fits there, else over it. + pub(super) fn show_session_info(&mut self, cx: &mut Context) { + if !self.session_info_fits(cx) { + self.session_info_overlay = true; + } else if self.settings.session_info_hidden { + self.settings.session_info_hidden = false; + self.schedule_save(cx); + } + cx.notify(); + } + /// Dismiss the card floating over the transcript; one shown beside it /// stays put. - fn close_session_info_overlay(&mut self, cx: &mut Context) { + pub(super) fn close_session_info_overlay(&mut self, cx: &mut Context) { if self.session_info_overlay { self.session_info_overlay = false; cx.notify(); @@ -217,35 +231,15 @@ impl Shell { .tooltip(crate::settings::widgets::text_tooltip(branch)) }); - // ── This conversation. - let busy = self.side_chat_creating; - let side_chat = popover::picker_row(&theme, false, false, "session-info-side-chat") - .id("session-info-side-chat") - .debug_selector(|| "session-info-side-chat".into()) - .when(busy, |row| row.opacity(0.5)) - .on_click(cx.listener(|this, _, _, cx| { - this.close_session_info_overlay(cx); - this.create_child_chat(None, cx) - })) - .child(popover::picker_icon_slot(row_glyph(icons::PLUS, &theme))) - .child(div().flex_1().child("New side chat")); - let fork = popover::picker_row(&theme, false, false, "session-info-fork") - .id("session-info-fork") - .debug_selector(|| "session-info-fork".into()) - .when(busy, |row| row.opacity(0.5)) - .on_click(cx.listener(|this, _, _, cx| { - this.close_session_info_overlay(cx); - this.create_side_chat(cx) - })) - .child(popover::picker_icon_slot(row_glyph(icons::FORK, &theme))) - .child(div().flex_1().child("Fork chat")); - // ── The project's run actions. let actions = self.render_session_info_actions(&theme, cx); + // ── What runs alongside it. + let lanes = self.render_session_lanes(&chat, !docked, &theme, cx); + let body = div() .id("session-info-body") - .max_h(px(max_height.max(120.0))) + .min_h_0() .overflow_y_scroll() // The card's inset lives inside the scroller, so dividers can // bleed through it edge to edge without being clipped. @@ -256,13 +250,13 @@ impl Shell { .child(device_row) .child(project_row) .children(branch_row) - .child(popover::picker_divider()) - .child(side_chat) - .child(fork) .children(actions); let card = div() .w(px(SESSION_CARD_WIDTH)) + .min_h_0() + .flex() + .flex_col() .rounded(px(popover::CARD_RADIUS)) .overflow_hidden() .border_1() @@ -270,6 +264,8 @@ impl Shell { .bg(popover::surface_bg(&theme)) .when(!docked && !theme.is_frost(), |card| card.shadow_lg()) .child(body); + let frosted = + |card| crate::frost::frosted(popover::CARD_RADIUS, crate::frost::MENU_BLUR, card); // Docked, the card slides in from the right beside the column; // floating, it drops in from just above. Either way it is placed from @@ -287,6 +283,12 @@ impl Shell { .absolute() .top(px(Theme::TITLEBAR_HEIGHT + 12.0 + dy)) .right(px(SESSION_CARD_INSET - dx)) + // The two cards share the height below the titlebar, each + // scrolling inside what it gets. + .max_h(px(max_height.max(240.0))) + .flex() + .flex_col() + .gap(px(SESSION_CARDS_GAP)) .opacity(ease) .occlude() // Over the transcript it is a popover: a press elsewhere, or @@ -296,11 +298,8 @@ impl Shell { cx.listener(|this, _, _, cx| this.close_session_info_overlay(cx)), ) }) - .child(crate::frost::frosted( - popover::CARD_RADIUS, - crate::frost::MENU_BLUR, - card, - )) + .child(frosted(card.into_any_element())) + .child(frosted(lanes)) .into_any_element(), ) } diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs new file mode 100644 index 0000000000..3d4f1677cb --- /dev/null +++ b/crates/ui/src/shell/session_lanes.rs @@ -0,0 +1,872 @@ +//! The lanes card, stacked under the session card and shown with it: what +//! runs alongside the open chat — its **side chats** (forks, and chats an +//! agent spawned through the Zeron MCP server) and its **subagents** (the +//! spawn chips of its transcript, with their live status). Each lane is a +//! section of session-card rows whose status marks sit on one thin track; +//! clicking a row opens it in the right pane. Settled subagents fold into one +//! "finished" row, closed until opened. + +use gpui::Hsla; +use zeron_doc::{MessagePart, SubagentStatus}; +use zeron_proto::{Chat, ChatIndicator}; + +use super::session_info::SESSION_CARD_WIDTH; +use super::*; + +/// The track runs through the rows' icon slots (the picker row's 8px pad +/// plus half the 16px slot), between their marks. +const TRACK_X: f32 = 15.5; +/// The middle of a picker row, where its mark sits. +const ROW_MID: f32 = popover::PICKER_ROW_HEIGHT / 2.0; +/// How far the track stops short of a mark's center. +const TRACK_CLEARANCE: f32 = 8.0; +/// Rows a lane shows before "Show more" pages it, and the page size. +const INITIAL_ROWS: usize = 8; +const PAGE_ROWS: usize = 10; + +/// What the lanes card has unfolded or paged; it follows the open chat. +#[derive(Debug, Default)] +pub(super) struct LaneState { + chat_id: String, + finished_open: bool, + shown_chats: usize, + shown_finished: usize, +} + +impl LaneState { + /// Folds and pages are per chat: another chat starts from rest. + fn follow(&mut self, chat_id: &str) { + if self.chat_id != chat_id { + *self = Self { + chat_id: chat_id.to_owned(), + ..Self::default() + }; + } + } + + fn shown(count: usize) -> usize { + count.max(INITIAL_ROWS) + } + + /// Page the side-chat lane until its `index`th branch shows. + pub(super) fn reveal_chat(&mut self, chat_id: &str, index: usize) { + self.follow(chat_id); + if index >= Self::shown(self.shown_chats) { + self.shown_chats = + INITIAL_ROWS + (index + 1 - INITIAL_ROWS).div_ceil(PAGE_ROWS) * PAGE_ROWS; + } + } +} + +/// A spawn chip of the open transcript, as its lane lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SubagentRow { + pub doc_id: String, + pub title: SharedString, + pub status: Option, + /// When the latest turn that spawned (or steered) it was written — the + /// closest thing a subagent has to a last-updated time. + pub spawned_at: DateTime, +} + +impl SubagentRow { + /// A settled subagent is frozen: its tab reads the uploaded blob first. + pub fn frozen(&self) -> bool { + matches!( + self.status, + Some(SubagentStatus::Done) | Some(SubagentStatus::Failed) + ) + } + + fn indicator(&self) -> ChatIndicator { + match self.status { + Some(SubagentStatus::Running) => ChatIndicator::Working, + Some(SubagentStatus::Done) => ChatIndicator::Completed, + Some(SubagentStatus::Failed) => ChatIndicator::Errored, + None => ChatIndicator::Idle, + } + } +} + +/// The open chat's subagents, one row per subagent doc: running ones first, +/// longest-running leading, then the settled ones most recently updated first +/// (later spawns lead within one turn). +/// Only genuine spawn chips with a stamped doc ref qualify — the chip IS the +/// index (there is no listing endpoint), and a stray ref on a non-Agent tool +/// must not surface as a phantom subagent. +pub(super) fn subagent_rows(state: &AppState, chat_id: &str) -> Vec { + if state.selected_chat.as_deref() != Some(chat_id) { + return Vec::new(); + } + let mut rows: Vec = Vec::new(); + for entry in &state.transcript { + let spawned_at = + DateTime::::from_timestamp_millis(entry.created_at).unwrap_or_else(Utc::now); + for part in &entry.parts { + let MessagePart::Tool { + call, + subagent_ref: Some(doc_id), + subagent_status, + .. + } = part + else { + continue; + }; + if !call.is_subagent_spawn() { + continue; + } + let row = SubagentRow { + doc_id: doc_id.clone(), + title: crate::transcript::subagent_tab_title(call), + status: *subagent_status, + spawned_at, + }; + match rows.iter_mut().find(|r| r.doc_id == row.doc_id) { + // A reopened (steered) subagent updates its row in place. + Some(existing) => *existing = row, + None => rows.push(row), + } + } + } + // Stable sort over the reversed spawn order: ties keep the later spawn + // on top. + rows.reverse(); + rows.sort_by_key(|row| std::cmp::Reverse(row.spawned_at)); + // Running subagents lead, longest-running first: one started long ago is + // buried under everything spawned since, and is the one to find and steer. + // Oldest-first also keeps the lane still as new subagents join at its + // foot. The settled tail keeps the newest-first order above. + let (mut running, settled): (Vec<_>, Vec<_>) = rows + .into_iter() + .partition(|row| row.status == Some(SubagentStatus::Running)); + // Ties keep spawn order, like the lane's oldest-first order. + running.reverse(); + running.sort_by_key(|row| row.spawned_at); + running.extend(settled); + running +} + +/// The subagent lane: what has not settled rides it, the rest folds away. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SubagentLane { + /// Running first, then any row whose status is not stamped yet. + pub active: Vec, + /// Done and failed, most recently updated first. + pub finished: Vec, +} + +impl SubagentLane { + pub fn new(rows: Vec) -> Self { + let (finished, active) = rows.into_iter().partition(SubagentRow::frozen); + Self { active, finished } + } + + pub fn running(&self) -> usize { + self.active + .iter() + .filter(|row| row.status == Some(SubagentStatus::Running)) + .count() + } + + pub fn failed(&self) -> usize { + self.finished + .iter() + .filter(|row| row.status == Some(SubagentStatus::Failed)) + .count() + } + + pub fn is_empty(&self) -> bool { + self.active.is_empty() && self.finished.is_empty() + } +} + +/// A side chat of the open chat, as its lane lists it. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct ChildChatRow { + pub chat_id: String, + pub device_id: String, + pub title: SharedString, + pub status: ChatIndicator, + pub time_ago: SharedString, + /// The chat's linked pull request, drawn as the sidebar's badge. + pub change_request: Option, + activity: DateTime, +} + +/// The live (unarchived) children of `chat_id`, most recent activity first — +/// the same order the sidebar's Sessions list keeps. +pub(super) fn child_chat_rows( + state: &AppState, + chat_id: &str, + now: DateTime, +) -> Vec { + let mut rows: Vec = state + .chats + .iter() + .filter(|chat| !chat.archived && chat.parent_chat_id.as_deref() == Some(chat_id)) + .map(|chat| { + let activity = chat.last_message_at.unwrap_or(chat.created_at); + ChildChatRow { + chat_id: chat.id.clone(), + device_id: chat.device_id.clone(), + title: child_chat_title(chat).into(), + status: state.display_status_for(chat, now), + time_ago: zeron_proto::view::format_time_ago(activity, now).into(), + change_request: state.change_request_for_chat(chat).cloned(), + activity, + } + }) + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.activity)); + rows +} + +/// A side chat titles itself on its first turn; until then the preview or a +/// placeholder stands in. +pub(super) fn child_chat_title(chat: &Chat) -> String { + chat.title + .clone() + .or_else(|| chat.last_message_preview.clone()) + .unwrap_or_else(|| "New side chat".into()) +} + +/// A lane's track: the 1px rule through its rows' icon slots, broken +/// around each status mark like a line between stations. `first` and `last` +/// end it at the lane's first and last marks. +fn track( + row: gpui::Stateful, + first: bool, + last: bool, + color: Hsla, +) -> gpui::Stateful { + let segment = || div().absolute().left(px(TRACK_X)).w(px(1.0)).bg(color); + row.relative() + .when(!first, |row| { + // Up through the gap to the row above. + row.child( + segment() + .top(px(-popover::MENU_GAP)) + .h(px(ROW_MID - TRACK_CLEARANCE + popover::MENU_GAP)), + ) + }) + .when(!last, |row| { + row.child(segment().top(px(ROW_MID + TRACK_CLEARANCE)).bottom_0()) + }) +} + +/// A status mark in a row's icon slot: Working spins, Completed wears the +/// check, the rest a dot in the status color. +fn status_mark( + key: String, + status: ChatIndicator, + view: gpui::EntityId, + theme: &Theme, + cx: &mut App, +) -> AnyElement { + let color = spaces::status_dot_color(status, theme); + match status { + ChatIndicator::Completed => icon(icons::CHECK) + .size(px(12.0)) + .text_color(color) + .into_any_element(), + ChatIndicator::Working => { + loaders::mini_glyph_spinner(format!("{key}-working"), 2.0, theme.glyph, view, cx) + .into_any_element() + } + _ => div() + .size(px(6.0)) + .rounded_full() + .bg(color) + .into_any_element(), + } +} + +fn glyph(path: &'static str, theme: &Theme) -> AnyElement { + icon(path) + .size(px(15.0)) + .text_color(theme.text_muted) + .into_any_element() +} + +/// A row's trailing time, in the muted subline size. +fn time_label(text: SharedString, theme: &Theme) -> gpui::Div { + div() + .flex_none() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(text) +} + +/// A row's title, dissolving at its right edge like the sidebar's. +fn row_title(id: String, title: SharedString) -> AnyElement { + div() + .flex_1() + .min_w_0() + .child(sidebar_faded_label( + id.into(), + true, + div().line_height(px(17.0)).child(title), + )) + .into_any_element() +} + +/// A section heading, with any badge trailing its label. +fn heading(theme: &Theme, label: &str, badge: Option) -> AnyElement { + popover::menu_heading(theme, label) + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .children(badge) + .into_any_element() +} + +impl Shell { + /// Show the card with `chat_id`'s side-chat row paged in, so an inline + /// rename has its row on screen; false when it is not one of the open + /// chat's live side chats. + pub(super) fn reveal_session_lane_chat( + &mut self, + chat_id: &str, + cx: &mut Context, + ) -> bool { + let parent = self.active_chat.clone(); + let rows = child_chat_rows(self.state.read(cx), &parent, Utc::now()); + let Some(index) = rows.iter().position(|row| row.chat_id == chat_id) else { + return false; + }; + self.session_lanes.reveal_chat(&parent, index); + self.show_session_info(cx); + true + } + + /// The lanes card for the open chat; `floating` over the transcript, it + /// casts the popover's shadow. + pub(super) fn render_session_lanes( + &mut self, + chat: &Chat, + floating: bool, + theme: &Theme, + cx: &mut Context, + ) -> AnyElement { + let now = Utc::now(); + self.session_lanes.follow(&chat.id); + let (side_chats, subagents) = { + let state = self.state.read(cx); + ( + child_chat_rows(state, &chat.id, now), + SubagentLane::new(subagent_rows(state, &chat.id)), + ) + }; + let rail = theme.text_muted.opacity(0.25); + let view = cx.entity_id(); + let mut rows: Vec = vec![heading(theme, "Side chats", None)]; + + // ── Side chats, then the two ways to start one. + let shown = LaneState::shown(self.session_lanes.shown_chats); + let hidden = side_chats.len().saturating_sub(shown); + let lane = side_chats.len().min(shown); + for (i, row) in side_chats.into_iter().take(shown).enumerate() { + let row = self.render_side_chat_row(row, view, theme, cx); + rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); + } + if hidden > 0 { + rows.push(self.render_show_more( + "session-lanes-chats-more", + hidden, + theme, + cx, + |lanes| { + lanes.shown_chats = LaneState::shown(lanes.shown_chats) + PAGE_ROWS; + }, + )); + } + let busy = self.side_chat_creating; + for (id, path, label, fork) in [ + ( + "session-info-side-chat", + icons::PLUS, + "New side chat", + false, + ), + ("session-info-fork", icons::FORK, "Fork chat", true), + ] { + rows.push( + popover::picker_row(theme, false, false, id) + .id(id) + .debug_selector(move || id.into()) + .when(busy, |row| row.opacity(0.5)) + .on_click(cx.listener(move |this, _, _, cx| { + this.close_session_info_overlay(cx); + if fork { + this.create_side_chat(cx); + } else { + this.create_child_chat(None, cx); + } + })) + .child(popover::picker_icon_slot(glyph(path, theme))) + .child(div().flex_1().child(label)) + .into_any_element(), + ); + } + + // ── Subagents: what runs rides the lane, the settled fold away. + if !subagents.is_empty() { + let running = subagents.running(); + let failed = subagents.failed(); + let pill = (running > 0).then(|| { + crate::running_pill::running_pill("session-lanes-subagents", running as u32, theme) + }); + rows.push(popover::picker_divider().into_any_element()); + rows.push(heading(theme, "Subagents", pill)); + let SubagentLane { active, finished } = subagents; + let open = self.session_lanes.finished_open; + let shown = LaneState::shown(self.session_lanes.shown_finished); + let hidden = if open { + finished.len().saturating_sub(shown) + } else { + 0 + }; + let folded = finished.len(); + let finished: Vec<_> = if open { + finished.into_iter().take(shown).collect() + } else { + Vec::new() + }; + // Running, the fold, and what the fold opens share one track. + let lane = active.len() + usize::from(folded > 0) + finished.len(); + let mut i = 0; + for row in active { + let row = self.render_subagent_row(row, view, theme, cx); + rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); + i += 1; + } + if folded > 0 { + let row = self.render_finished_fold(folded, failed, open, theme, cx); + rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); + i += 1; + } + for row in finished { + let row = self.render_subagent_row(row, view, theme, cx); + rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); + i += 1; + } + if hidden > 0 { + rows.push(self.render_show_more( + "session-lanes-finished-more", + hidden, + theme, + cx, + |lanes| { + lanes.shown_finished = LaneState::shown(lanes.shown_finished) + PAGE_ROWS; + }, + )); + } + } + + let body = div() + .id("session-lanes-body") + .min_h_0() + .overflow_y_scroll() + // The inset lives inside the scroller, so dividers bleed edge to + // edge as they do in the session card. + .p(px(popover::CARD_INSET)) + .flex() + .flex_col() + .gap(px(popover::MENU_GAP)) + .children(rows); + div() + .id("session-lanes") + .debug_selector(|| "session-lanes".into()) + .w(px(SESSION_CARD_WIDTH)) + .min_h_0() + .flex() + .flex_col() + .rounded(px(popover::CARD_RADIUS)) + .overflow_hidden() + .border_1() + .border_color(theme.border) + .bg(popover::surface_bg(theme)) + .when(floating && !theme.is_frost(), |card| card.shadow_lg()) + .child(body) + .into_any_element() + } + + fn render_side_chat_row( + &mut self, + row: ChildChatRow, + view: gpui::EntityId, + theme: &Theme, + cx: &mut Context, + ) -> gpui::Stateful { + let key = format!("session-lanes-chat-{}", row.chat_id); + let mark = status_mark(key.clone(), row.status, view, theme, cx); + let title = match self.rename_input_for(&row.chat_id, ChatRenameSurface::SessionCard) { + Some(input) => chat_title_editor(format!("{key}-title-editor").into(), input, theme), + None => row_title(format!("{key}-title"), row.title.clone()), + }; + let open_id = row.chat_id.clone(); + let menu_id = row.chat_id.clone(); + popover::picker_row(theme, false, false, key.clone()) + .id(SharedString::from(key.clone())) + .aria_label(SharedString::from(format!("Open side chat {}", row.title))) + .on_click(cx.listener(move |this, event: &gpui::ClickEvent, _, cx| { + cx.stop_propagation(); + // The first click opened the tab; the second edits the title + // in place. + if event.click_count() >= 2 { + this.open_rename_chat(open_id.clone(), cx); + } else { + this.close_session_info_overlay(cx); + this.open_child_chat_tab(&open_id, cx); + } + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, _, cx| { + cx.stop_propagation(); + this.chat_menu.open(ChatMenuState { + chat_id: menu_id.clone(), + tab: None, + position: event.position, + page: ChatMenuPage::Root, + }); + cx.notify(); + }), + ) + .child(popover::picker_icon_slot(mark)) + .child(title) + .children(row.change_request.clone().map(|summary| { + crate::change_requests::pull_request_badge( + format!("{key}-pr").into(), + summary, + crate::change_requests::ChangeRequestBadgeSurface::Sidebar, + Some(row.device_id.clone()), + theme, + ) + })) + .child(time_label(row.time_ago.clone(), theme)) + } + + fn render_subagent_row( + &mut self, + row: SubagentRow, + view: gpui::EntityId, + theme: &Theme, + cx: &mut Context, + ) -> gpui::Stateful { + let key = format!("session-lanes-subagent-{}", row.doc_id); + let mark = status_mark(key.clone(), row.indicator(), view, theme, cx); + let time = zeron_proto::view::format_time_ago(row.spawned_at, Utc::now()); + let title = row_title(format!("{key}-title"), row.title.clone()); + popover::picker_row(theme, false, false, key.clone()) + .id(SharedString::from(key)) + .aria_label(SharedString::from(format!("Open subagent {}", row.title))) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.close_session_info_overlay(cx); + this.add_subagent_surface( + this.active_chat.clone(), + row.doc_id.clone(), + row.title.to_string(), + row.frozen(), + cx, + ); + })) + .child(popover::picker_icon_slot(mark)) + .child(title) + .child(time_label(time.into(), theme)) + } + + /// The settled subagents' fold: the count, with any failures called out. + fn render_finished_fold( + &mut self, + count: usize, + failed: usize, + open: bool, + theme: &Theme, + cx: &mut Context, + ) -> gpui::Stateful { + let chevron = icon(icons::ALT_ARROW_RIGHT) + .size(px(12.0)) + .text_color(theme.text_muted) + .with_transformation(gpui::Transformation::rotate(gpui::percentage(if open { + 0.25 + } else { + 0.0 + }))); + popover::picker_row(theme, false, false, "session-lanes-finished") + .id("session-lanes-finished") + .role(gpui::Role::Button) + .aria_label(SharedString::from(format!( + "{} {count} finished subagents", + if open { "Collapse" } else { "Expand" } + ))) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.session_lanes.finished_open = !this.session_lanes.finished_open; + cx.notify(); + })) + .child(popover::picker_icon_slot(chevron.into_any_element())) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(theme.text_muted) + .child(SharedString::from(format!("{count} finished"))), + ) + .when(failed > 0, |row| { + row.child( + div() + .flex_none() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(spaces::status_dot_color(ChatIndicator::Errored, theme)) + .child(SharedString::from(format!("{failed} failed"))), + ) + }) + } + + /// "Show N more" under a paged lane, its label lined up with the rows'. + fn render_show_more( + &mut self, + id: &'static str, + remaining: usize, + theme: &Theme, + cx: &mut Context, + page: fn(&mut LaneState), + ) -> AnyElement { + popover::picker_row(theme, false, false, id) + .id(id) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + page(&mut this.session_lanes); + cx.notify(); + })) + .child(popover::picker_icon_slot(div().into_any_element())) + .child( + div() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child(format!("Show {} more", remaining.min(PAGE_ROWS))), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zeron_doc::{MessageRole, MessageStatus, SessionMessageEntry}; + use zeron_proto::ToolCall; + + fn chat(id: &str, parent: Option<&str>, minutes_ago: i64) -> Chat { + serde_json::from_value(serde_json::json!({ + "id": id, + "deviceId": "dev", + "archived": false, + "createdAt": Utc::now() - chrono::Duration::minutes(minutes_ago), + "parentChatId": parent, + })) + .unwrap() + } + + fn spawn( + id: &str, + name: &str, + doc: Option<&str>, + status: Option, + ) -> MessagePart { + MessagePart::Tool { + id: id.into(), + call: ToolCall::Unknown { + name: name.into(), + input: Some(serde_json::json!({ "description": "verify the marker pipeline" })), + }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: doc.map(str::to_owned), + subagent_status: status, + subagent_tail: None, + } + } + + fn entry(parts: Vec) -> SessionMessageEntry { + SessionMessageEntry { + duration_ms: None, + id: "e1".into(), + role: MessageRole::Assistant, + parts, + created_at: (Utc::now() - chrono::Duration::minutes(3)).timestamp_millis(), + device_id: "dev".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + } + } + + fn at(id: &str, minutes_ago: i64, parts: Vec) -> SessionMessageEntry { + SessionMessageEntry { + id: id.into(), + created_at: (Utc::now() - chrono::Duration::minutes(minutes_ago)).timestamp_millis(), + ..entry(parts) + } + } + + fn sub(id: &str, status: SubagentStatus) -> MessagePart { + spawn( + id, + &format!("Agent: {id}"), + Some(&format!("main--sub--{id}")), + Some(status), + ) + } + + #[test] + fn subagent_rows_list_only_stamped_spawn_chips_of_the_selected_chat() { + let mut state = AppState::new(); + state.selected_chat = Some("main".into()); + state.transcript = vec![entry(vec![ + spawn( + "t1", + "Agent: verify", + Some("main--sub--t1"), + Some(SubagentStatus::Running), + ), + // No doc ref yet: the engine stamps it asynchronously. + spawn("t2", "Agent: later", None, None), + // A stray ref on a non-spawn tool never surfaces. + spawn( + "t3", + "Read", + Some("main--sub--t3"), + Some(SubagentStatus::Done), + ), + spawn( + "t4", + "Agent: done", + Some("main--sub--t4"), + Some(SubagentStatus::Done), + ), + ])]; + let rows = subagent_rows(&state, "main"); + assert_eq!( + rows.iter().map(|r| r.doc_id.as_str()).collect::>(), + ["main--sub--t1", "main--sub--t4"] + ); + // The bare task, genus stripped — the same title the tab wears. + assert_eq!(rows[0].title.as_ref(), "verify"); + assert!(rows[1].frozen()); + assert!(!rows[0].frozen()); + // Spawn time comes from the turn that carried the chip. + assert!((Utc::now() - rows[0].spawned_at).num_minutes() >= 2); + // Another chat sees nothing of this transcript. + assert!(subagent_rows(&state, "other").is_empty()); + } + + #[test] + fn a_steered_subagent_moves_up_with_its_newer_turn() { + let mut state = AppState::new(); + state.selected_chat = Some("main".into()); + state.transcript = vec![ + at( + "e1", + 30, + vec![ + sub("a", SubagentStatus::Done), + sub("b", SubagentStatus::Done), + ], + ), + at("e2", 20, vec![sub("c", SubagentStatus::Done)]), + at("e3", 10, vec![sub("a", SubagentStatus::Done)]), + ]; + let ids: Vec<_> = subagent_rows(&state, "main") + .into_iter() + .map(|r| r.doc_id) + .collect(); + assert_eq!(ids, ["main--sub--a", "main--sub--c", "main--sub--b"]); + } + + #[test] + fn running_subagents_lead_longest_running_first() { + let mut state = AppState::new(); + state.selected_chat = Some("main".into()); + state.transcript = vec![ + at("e1", 90, vec![sub("old-run", SubagentStatus::Running)]), + at("e2", 60, vec![sub("old-done", SubagentStatus::Done)]), + at("e3", 30, vec![sub("mid-run", SubagentStatus::Running)]), + at("e4", 20, vec![sub("mid-fail", SubagentStatus::Failed)]), + at("e5", 10, vec![sub("new-done", SubagentStatus::Done)]), + at("e6", 5, vec![sub("new-run", SubagentStatus::Running)]), + ]; + let lane = SubagentLane::new(subagent_rows(&state, "main")); + let ids = |rows: &[SubagentRow]| -> Vec { + rows.iter().map(|r| r.doc_id.clone()).collect() + }; + // The running lead the lane, longest-running first; the settled fold + // away newest first. + assert_eq!( + ids(&lane.active), + [ + "main--sub--old-run", + "main--sub--mid-run", + "main--sub--new-run" + ] + ); + assert_eq!( + ids(&lane.finished), + [ + "main--sub--new-done", + "main--sub--mid-fail", + "main--sub--old-done" + ] + ); + assert_eq!((lane.running(), lane.failed()), (3, 1)); + } + + #[test] + fn child_chat_rows_are_live_children_newest_first() { + let mut state = AppState::new(); + let mut archived = chat("old", Some("main"), 1); + archived.archived = true; + let mut titled = chat("b", Some("main"), 30); + titled.title = Some("Investigate caching".into()); + state.apply_chats(vec![ + chat("main", None, 60), + chat("a", Some("main"), 5), + titled, + chat("unrelated", Some("elsewhere"), 2), + archived, + ]); + let rows = child_chat_rows(&state, "main", Utc::now()); + assert_eq!( + rows.iter().map(|r| r.chat_id.as_str()).collect::>(), + ["a", "b"] + ); + assert_eq!(rows[0].title.as_ref(), "New side chat"); + assert_eq!(rows[1].title.as_ref(), "Investigate caching"); + assert_eq!(rows[0].status, ChatIndicator::Idle); + } + + #[test] + fn revealing_a_side_chat_pages_its_lane_in_steps() { + let mut lanes = LaneState::default(); + lanes.reveal_chat("main", 3); + assert_eq!(LaneState::shown(lanes.shown_chats), INITIAL_ROWS); + lanes.reveal_chat("main", INITIAL_ROWS); + assert_eq!( + LaneState::shown(lanes.shown_chats), + INITIAL_ROWS + PAGE_ROWS + ); + // Another chat's lanes start from rest. + lanes.finished_open = true; + lanes.reveal_chat("other", 0); + assert!(!lanes.finished_open); + assert_eq!(LaneState::shown(lanes.shown_chats), INITIAL_ROWS); + } +} diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 77cc136741..08d7c2ad40 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -544,15 +544,6 @@ impl Shell { .aria_label(files_panel_label) .when(self.files_panel_open(cx) && fit.files, |button| { button.bg(crate::theme::wash(0.09)) - }) - // Running subagents live in this panel: mark the - // button so they are findable while it is closed. - .when(running_subagents > 0, |button| { - crate::running_pill::mark_files_button( - button, - running_subagents, - &theme, - ) }), ) .child(header_icon_button_with( @@ -600,6 +591,15 @@ impl Shell { cx.listener(|this, _, _, _| this.note_session_info_press()), ) .when(open, |button| button.bg(crate::theme::wash(0.09))) + // Running subagents ride the card's lanes: mark the button so + // they are findable while the card is closed. + .when(!open && running_subagents > 0, |button| { + crate::running_pill::mark_titlebar_button( + button, + running_subagents, + &theme, + ) + }) }); let inner = div() .size_full() diff --git a/docs/mcp.md b/docs/mcp.md index 0cf820a675..5bda56f51c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -90,16 +90,17 @@ the MCP child when the Pi session shuts down. Title runs never carry it. A run with no served port (embedded engine that lost the bind) gets no Zeron tools rather than a dead server. -### Forks and the explorer +### Forks and the session card `ForkSideChat { chatId, sourceChatId, parentChatId? }` copies a chat's history through its latest completed response into a new chat and appends a `fork` part (a system entry: `sourceChatId`, `sourceTitle`) as the seam; the transcript draws it as "This chat was forked from ". `parentChatId` defaults to the source; a side chat's own fork button passes its parent so -the copy lists as a sibling. The file explorer's footer lists a chat's -**Subagents** (its spawn chips) and **Chats** (its children: forks and -`kind: "side"` spawns) and opens either in the right pane. +the copy lists as a sibling. The lanes card, under the session card, draws +a chat as a main line with its **side chats** (its children: forks and +`kind: "side"` spawns) and **subagents** (its spawn chips) branching off it, +and opens either in the right pane. ## Tools From db27e9765460156afd72d395719469243f8551e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Sun, 11 Oct 2026 22:37:11 -0300 Subject: [PATCH 02/17] Session card: drop the running pill from the Subagents heading The lane's own spinners show what runs, and the session card's toggle keeps the count while the card is closed. --- crates/ui/src/shell/session_lanes.rs | 29 ++++------------------------ 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 3d4f1677cb..33f94b78c7 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -161,13 +161,6 @@ impl SubagentLane { Self { active, finished } } - pub fn running(&self) -> usize { - self.active - .iter() - .filter(|row| row.status == Some(SubagentStatus::Running)) - .count() - } - pub fn failed(&self) -> usize { self.finished .iter() @@ -310,17 +303,6 @@ fn row_title(id: String, title: SharedString) -> AnyElement { .into_any_element() } -/// A section heading, with any badge trailing its label. -fn heading(theme: &Theme, label: &str, badge: Option<AnyElement>) -> AnyElement { - popover::menu_heading(theme, label) - .flex() - .flex_row() - .items_center() - .gap(px(6.0)) - .children(badge) - .into_any_element() -} - impl Shell { /// Show the card with `chat_id`'s side-chat row paged in, so an inline /// rename has its row on screen; false when it is not one of the open @@ -360,7 +342,8 @@ impl Shell { }; let rail = theme.text_muted.opacity(0.25); let view = cx.entity_id(); - let mut rows: Vec<AnyElement> = vec![heading(theme, "Side chats", None)]; + let mut rows: Vec<AnyElement> = + vec![popover::menu_heading(theme, "Side chats").into_any_element()]; // ── Side chats, then the two ways to start one. let shown = LaneState::shown(self.session_lanes.shown_chats); @@ -412,13 +395,9 @@ impl Shell { // ── Subagents: what runs rides the lane, the settled fold away. if !subagents.is_empty() { - let running = subagents.running(); let failed = subagents.failed(); - let pill = (running > 0).then(|| { - crate::running_pill::running_pill("session-lanes-subagents", running as u32, theme) - }); rows.push(popover::picker_divider().into_any_element()); - rows.push(heading(theme, "Subagents", pill)); + rows.push(popover::menu_heading(theme, "Subagents").into_any_element()); let SubagentLane { active, finished } = subagents; let open = self.session_lanes.finished_open; let shown = LaneState::shown(self.session_lanes.shown_finished); @@ -826,7 +805,7 @@ mod tests { "main--sub--old-done" ] ); - assert_eq!((lane.running(), lane.failed()), (3, 1)); + assert_eq!(lane.failed(), 1); } #[test] From 5c319da03fa5f8517aa3ed7fd12d757a3a67fbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Sun, 11 Oct 2026 22:46:43 -0300 Subject: [PATCH 03/17] Session card: badge the toggle while side chats or subagents run The count now adds working side chats to running subagents, and stays on the toggle with the card open or closed until nothing runs. --- crates/ui/src/running_pill.rs | 33 +++++++++++++++------------------ crates/ui/src/shell/tabs.rs | 34 +++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/crates/ui/src/running_pill.rs b/crates/ui/src/running_pill.rs index 3b3114a8b9..1f7cc7903e 100644 --- a/crates/ui/src/running_pill.rs +++ b/crates/ui/src/running_pill.rs @@ -246,30 +246,27 @@ fn paint_pill( let _ = line.paint(text_origin, height, TextAlign::Left, None, window, cx); } -/// Marks a titlebar button while subagents run behind it, so they can be -/// found with its card or panel closed: a small count badge in the activity -/// tone sits on its corner, drawn over the button so it keeps its size and -/// position among the titlebar controls. It holds still; the count is the -/// signal. +/// Marks a titlebar button while work runs behind it: a small count badge +/// in the activity tone sits on its corner, drawn over the button so it +/// keeps its size and position among the titlebar controls. It holds still; +/// the count is the signal. pub fn mark_titlebar_button( button: gpui::Stateful<gpui::Div>, count: u32, theme: &Theme, ) -> gpui::Stateful<gpui::Div> { let tone = theme.busy; - button - .relative() - .child( - div() - .absolute() - .top(px(-3.0)) - .right(px(-3.0)) - .child(CountBadge { - count, - tone, - family: theme.font_mono.clone(), - }), - ) + button.relative().child( + div() + .absolute() + .top(px(-3.0)) + .right(px(-3.0)) + .child(CountBadge { + count, + tone, + family: theme.font_mono.clone(), + }), + ) } /// The count badge on the titlebar button, painted rather than laid out so diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 08d7c2ad40..a296d66113 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -420,12 +420,24 @@ impl Shell { content_width, } = self.titlebar_trailing_layout(on_canvas, cx); - let running_subagents = { + // What runs alongside the open chat: its working subagents and side + // chats, counted on the session card's toggle. + let running_lanes = { let state = self.state.read(cx); - state - .selected_chat - .as_deref() - .map_or(0, |chat| state.running_subagents_for(chat, Utc::now())) + let now = Utc::now(); + state.selected_chat.as_deref().map_or(0, |chat| { + let side_chats = state + .chats + .iter() + .filter(|side| { + !side.archived + && side.parent_chat_id.as_deref() == Some(chat) + && state.display_status_for(side, now) + == zeron_proto::ChatIndicator::Working + }) + .count() as u32; + state.running_subagents_for(chat, now) + side_chats + }) }; let trailing: Option<gpui::AnyElement> = if on_canvas { None @@ -591,14 +603,10 @@ impl Shell { cx.listener(|this, _, _, _| this.note_session_info_press()), ) .when(open, |button| button.bg(crate::theme::wash(0.09))) - // Running subagents ride the card's lanes: mark the button so - // they are findable while the card is closed. - .when(!open && running_subagents > 0, |button| { - crate::running_pill::mark_titlebar_button( - button, - running_subagents, - &theme, - ) + // Running subagents and side chats ride the card's lanes: the + // button counts them, open or closed, for as long as they run. + .when(running_lanes > 0, |button| { + crate::running_pill::mark_titlebar_button(button, running_lanes, &theme) }) }); let inner = div() From dd4a57e69b8fc2732148cfe9dd407e2c9da7e580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Sun, 11 Oct 2026 22:58:40 -0300 Subject: [PATCH 04/17] Fix side chat rename opening the file explorer --- crates/ui/src/shell.rs | 9 +--- .../ui/src/shell/panel_interaction_tests.rs | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 78e4978cd4..5831ab29da 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -10573,13 +10573,8 @@ impl Shell { } else { let rename = popover::menu_row(&theme, false, format!("chat-menu-rename-{chat_id}")) .id("chat-menu-rename") - .on_click(cx.listener(move |this, _, window, cx| { - // A side chat's title is edited on its row under the - // file tree: dock the explorer when the menu came - // from its tab. - if is_side_chat && !this.files_panel_open(cx) { - this.add_files_surface(window, cx); - } + .debug_selector(|| "chat-menu-rename".into()) + .on_click(cx.listener(move |this, _, _, cx| { this.open_rename_chat(rename_id.clone(), cx) })) .child(icon(icons::PEN).size(px(16.0)).text_color(theme.text_muted)) diff --git a/crates/ui/src/shell/panel_interaction_tests.rs b/crates/ui/src/shell/panel_interaction_tests.rs index 84dded5ab0..35f28c5f75 100644 --- a/crates/ui/src/shell/panel_interaction_tests.rs +++ b/crates/ui/src/shell/panel_interaction_tests.rs @@ -393,3 +393,44 @@ fn panels_share_space_and_keep_rendered_geometry_through_their_lifecycle(cx: &mu expect(settled(&shell, cx), landed.widths); } } + +#[gpui::test] +fn side_chat_menu_rename_keeps_the_editor_visible_without_opening_files(cx: &mut TestAppContext) { + let (_dir, shell, cx) = setup(cx); + change(&shell, cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id": "side", "title": "Side chat", "parentChatId": "a", + "deviceId": "local", "archived": false, "createdAt": Utc::now(), + })) + .unwrap(), + ); + cx.notify(); + }); + shell.chat_menu.open(ChatMenuState { + chat_id: "side".into(), + tab: None, + position: gpui::point(px(800.0), px(200.0)), + page: ChatMenuPage::Root, + }); + }); + settled(&shell, cx); + assert!(shell.read_with(cx, |shell, cx| !shell.files_panel_open(cx))); + + let rename = cx.debug_bounds("chat-menu-rename").unwrap(); + cx.simulate_click(rename.center(), Default::default()); + settled(&shell, cx); + + shell.read_with(cx, |shell, cx| { + assert!( + !shell.files_panel_open(cx), + "Rename must not open the explorer" + ); + assert!(shell.session_info_visible(cx)); + assert_eq!(shell.chat_rename.as_ref().unwrap().chat_id, "side"); + }); + let field = cx.debug_bounds("session-lanes-chat-side-title-editor").unwrap(); + let card = cx.debug_bounds("session-lanes").unwrap(); + assert!(field.top() >= card.top() && field.bottom() <= card.bottom()); +} From 23035c4611c22b9c122ef7c27d5ec8b9acc8cf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Sun, 11 Oct 2026 23:09:35 -0300 Subject: [PATCH 05/17] Session card: cap the lanes card at half the window's height However much runs, the lanes card stops at half the window and scrolls past it. The session card above keeps its height; only the lanes give. --- crates/ui/src/shell/session_info.rs | 13 ++++++++----- crates/ui/src/shell/session_lanes.rs | 7 +++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/shell/session_info.rs b/crates/ui/src/shell/session_info.rs index e1d175f22c..aa012d048e 100644 --- a/crates/ui/src/shell/session_info.rs +++ b/crates/ui/src/shell/session_info.rs @@ -235,11 +235,13 @@ impl Shell { let actions = self.render_session_info_actions(&theme, cx); // ── What runs alongside it. - let lanes = self.render_session_lanes(&chat, !docked, &theme, cx); + // It stops at half the window's height, however much runs. + let lanes_height = self.viewport_height / 2.0; + let lanes = self.render_session_lanes(&chat, !docked, lanes_height, &theme, cx); let body = div() .id("session-info-body") - .min_h_0() + .max_h(px(max_height.max(120.0))) .overflow_y_scroll() // The card's inset lives inside the scroller, so dividers can // bleed through it edge to edge without being clipped. @@ -252,9 +254,10 @@ impl Shell { .children(branch_row) .children(actions); + // The session card keeps its height; only the lanes under it give. let card = div() .w(px(SESSION_CARD_WIDTH)) - .min_h_0() + .flex_shrink_0() .flex() .flex_col() .rounded(px(popover::CARD_RADIUS)) @@ -283,8 +286,8 @@ impl Shell { .absolute() .top(px(Theme::TITLEBAR_HEIGHT + 12.0 + dy)) .right(px(SESSION_CARD_INSET - dx)) - // The two cards share the height below the titlebar, each - // scrolling inside what it gets. + // The lanes card scrolls inside what the session card leaves + // it below the titlebar. .max_h(px(max_height.max(240.0))) .flex() .flex_col() diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 33f94b78c7..66bdfe117d 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -322,12 +322,14 @@ impl Shell { true } - /// The lanes card for the open chat; `floating` over the transcript, it - /// casts the popover's shadow. + /// The lanes card for the open chat, at most `max_height` tall and + /// scrolling past it; `floating` over the transcript, it casts the + /// popover's shadow. pub(super) fn render_session_lanes( &mut self, chat: &Chat, floating: bool, + max_height: f32, theme: &Theme, cx: &mut Context<Self>, ) -> AnyElement { @@ -458,6 +460,7 @@ impl Shell { .id("session-lanes") .debug_selector(|| "session-lanes".into()) .w(px(SESSION_CARD_WIDTH)) + .max_h(px(max_height)) .min_h_0() .flex() .flex_col() From 7e0fa504b0f160053673373947e6179130420481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Sun, 11 Oct 2026 23:22:22 -0300 Subject: [PATCH 06/17] Scroll side chat rename fields into view --- .../ui/src/shell/panel_interaction_tests.rs | 63 +++++++++++++++++++ crates/ui/src/shell/session_lanes.rs | 53 +++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/shell/panel_interaction_tests.rs b/crates/ui/src/shell/panel_interaction_tests.rs index 35f28c5f75..c4ecac86ac 100644 --- a/crates/ui/src/shell/panel_interaction_tests.rs +++ b/crates/ui/src/shell/panel_interaction_tests.rs @@ -434,3 +434,66 @@ fn side_chat_menu_rename_keeps_the_editor_visible_without_opening_files(cx: &mut let card = cx.debug_bounds("session-lanes").unwrap(); assert!(field.top() >= card.top() && field.bottom() <= card.bottom()); } + +fn check_side_chat_rename_scroll(cx: &mut TestAppContext, width: f32, reduced_motion: bool) { + let (_dir, shell, cx) = setup(cx); + cx.simulate_resize(gpui::size(px(width), px(900.0))); + cx.update(|_, cx| motion::set_reduced_motion(cx, reduced_motion)); + change(&shell, cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + let now = Utc::now(); + for index in 0..40 { + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id": format!("side-{index}"), "title": format!("Side {index}"), + "parentChatId": "a", "deviceId": "local", "archived": false, + "createdAt": now - chrono::Duration::minutes(index), + })) + .unwrap(), + ); + } + cx.notify(); + }); + }); + settled(&shell, cx); + assert!(cx.debug_bounds("session-lanes").is_none()); + + // Reveal a paged-out row from a hidden card, then a row above the new + // scroll position. Both directions must leave the whole editor visible. + for (chat, selector) in [ + ("side-39", "session-lanes-chat-side-39-title-editor"), + ("side-0", "session-lanes-chat-side-0-title-editor"), + ] { + change(&shell, cx, |shell, _, cx| { + shell.open_rename_chat(chat.into(), cx); + }); + settled(&shell, cx); + for _ in 0..4 { + cx.run_until_parked(); + draw(&shell, cx, 100); + } + let field = cx.debug_bounds(selector).unwrap(); + let card = cx.debug_bounds("session-lanes").unwrap(); + assert!( + field.top() >= card.top() && field.bottom() <= card.bottom(), + "{chat}'s editor {field:?} is outside the card {card:?}" + ); + cx.update(|window, cx| { + shell.read_with(cx, |shell, cx| { + let rename = shell.chat_rename.as_ref().unwrap(); + assert_eq!(rename.chat_id, chat); + assert!(rename.input.focus_handle(cx).is_focused(window)); + }); + }); + } +} + +#[gpui::test] +fn side_chat_rename_scrolls_to_its_row_in_a_docked_card(cx: &mut TestAppContext) { + check_side_chat_rename_scroll(cx, 1600.0, false); +} + +#[gpui::test] +fn side_chat_rename_scrolls_to_its_row_in_a_floating_card_without_motion(cx: &mut TestAppContext) { + check_side_chat_rename_scroll(cx, 1000.0, true); +} diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 66bdfe117d..4afa12cc4d 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -31,6 +31,7 @@ pub(super) struct LaneState { finished_open: bool, shown_chats: usize, shown_finished: usize, + scroll: gpui::ScrollHandle, } impl LaneState { @@ -322,6 +323,34 @@ impl Shell { true } + /// Reveal the editor using the laid-out row, including while the card + /// opens. Paging alone can leave the focused field below the viewport. + fn keep_session_lane_rename_in_view( + &mut self, + row: gpui::Bounds<Pixels>, + cx: &mut Context<Self>, + ) { + if !self.chat_rename_revealing() { + return; + } + let scroll = &self.session_lanes.scroll; + let viewport = scroll.bounds(); + let inset = px(popover::CARD_INSET); + let delta = if row.top() < viewport.top() + inset { + row.top() - (viewport.top() + inset) + } else if row.bottom() > viewport.bottom() - inset { + row.bottom() - (viewport.bottom() - inset) + } else { + return; + }; + let offset = scroll.offset(); + let y = (offset.y - delta).clamp(-scroll.max_offset().y, px(0.0)); + if y != offset.y { + scroll.set_offset(gpui::point(offset.x, y)); + cx.notify(); + } + } + /// The lanes card for the open chat, at most `max_height` tall and /// scrolling past it; `floating` over the transcript, it casts the /// popover's shadow. @@ -449,6 +478,7 @@ impl Shell { .id("session-lanes-body") .min_h_0() .overflow_y_scroll() + .track_scroll(&self.session_lanes.scroll) // The inset lives inside the scroller, so dividers bleed edge to // edge as they do in the session card. .p(px(popover::CARD_INSET)) @@ -483,7 +513,9 @@ impl Shell { ) -> gpui::Stateful<gpui::Div> { let key = format!("session-lanes-chat-{}", row.chat_id); let mark = status_mark(key.clone(), row.status, view, theme, cx); - let title = match self.rename_input_for(&row.chat_id, ChatRenameSurface::SessionCard) { + let rename_input = self.rename_input_for(&row.chat_id, ChatRenameSurface::SessionCard); + let revealing = rename_input.is_some() && self.chat_rename_revealing(); + let title = match rename_input { Some(input) => chat_title_editor(format!("{key}-title-editor").into(), input, theme), None => row_title(format!("{key}-title"), row.title.clone()), }; @@ -518,6 +550,25 @@ impl Shell { ) .child(popover::picker_icon_slot(mark)) .child(title) + .when(revealing, |row| { + let shell = cx.weak_entity(); + row.child( + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.defer(cx, move |_, cx| { + shell + .update(cx, |this, cx| { + this.keep_session_lane_rename_in_view(bounds, cx) + }) + .ok(); + }); + }, + ) + .absolute() + .inset_0(), + ) + }) .children(row.change_request.clone().map(|summary| { crate::change_requests::pull_request_badge( format!("{key}-pr").into(), From 07cdfc06cbeb7ba302d19fa65b790deeacc6413f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Mon, 12 Oct 2026 18:07:10 -0300 Subject: [PATCH 07/17] Session card: a click on a side chat only opens it Renaming a side chat goes through its context menu; a second click no longer turns its row into a title field. --- crates/ui/src/shell/session_lanes.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 4afa12cc4d..b04df4063e 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -524,16 +524,10 @@ impl Shell { popover::picker_row(theme, false, false, key.clone()) .id(SharedString::from(key.clone())) .aria_label(SharedString::from(format!("Open side chat {}", row.title))) - .on_click(cx.listener(move |this, event: &gpui::ClickEvent, _, cx| { + .on_click(cx.listener(move |this, _, _, cx| { cx.stop_propagation(); - // The first click opened the tab; the second edits the title - // in place. - if event.click_count() >= 2 { - this.open_rename_chat(open_id.clone(), cx); - } else { - this.close_session_info_overlay(cx); - this.open_child_chat_tab(&open_id, cx); - } + this.close_session_info_overlay(cx); + this.open_child_chat_tab(&open_id, cx); })) .on_mouse_down( MouseButton::Right, From 56a37f72688a3f17872fc09cafc7c97ab66a7575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Mon, 12 Oct 2026 18:07:10 -0300 Subject: [PATCH 08/17] Demo: a session fanning out 40 subagents for the lanes card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock harness answers a `/fanout [N]` prompt (40 when bare, at most 200) by spawning N subagents at once and settling them one by one, oldest first, every seventh failing — the only data-side way to fill the session card's subagent lane, scroll it, and watch it drain into its finished fold. They settle `ZERON_MOCK_DELAY_MS` apart, 2.5s by default. scripts/dev-demo.sh seeds "Fan Out Forty Scouts" with three side chats and fans it out again on each launch. It also lists each demo folder once (a second space on the same folder was never created, so seeding failed with "no such space") and falls back to /tmp/zeron-demo-projects when ~/github is missing. --- crates/harness/src/mock.rs | 178 ++++++++++++++++++++++++++++++++++++- scripts/dev-demo.sh | 34 ++++++- 2 files changed, 208 insertions(+), 4 deletions(-) diff --git a/crates/harness/src/mock.rs b/crates/harness/src/mock.rs index e6d99e276e..fefb37be31 100644 --- a/crates/harness/src/mock.rs +++ b/crates/harness/src/mock.rs @@ -15,6 +15,108 @@ pub struct MockHarness { pub script: Vec<AgentEvent>, } +/// The subagent count a `/fanout [N]` prompt asks for (40 when bare). +fn fanout_count(prompt: &str) -> Option<usize> { + let rest = prompt.trim().strip_prefix("/fanout")?; + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return None; + } + let count = rest.trim().parse().unwrap_or(40); + Some(count.clamp(1, 200)) +} + +fn fanout_id(i: usize) -> String { + format!("mock-fanout-{i}") +} + +fn fanout_tag(i: usize, event: AgentEvent) -> AgentEvent { + AgentEvent::Subagent { + parent_tool_use_id: fanout_id(i), + event: Box::new(event), + } +} + +/// The `/fanout` opening: a line of text, then `count` spawn chips in the +/// claude driver's shape, each seeded with its task like a Task prompt. +fn fanout_spawns(count: usize) -> Vec<AgentEvent> { + const VERBS: [&str; 5] = ["Audit", "Map", "Profile", "Verify", "Trace"]; + const AREAS: [&str; 8] = [ + "the fold path", + "the commit cadence", + "the sync client", + "the registry doc", + "the composer dock", + "the sidebar sort", + "the diff reconciler", + "the relay transport", + ]; + let mut events = vec![AgentEvent::TextDelta { + text: format!("\n### Fan-out\n\nSending {count} scouts across the workspace.\n\n"), + }]; + for i in 0..count { + let description = format!( + "{} {}", + VERBS[i % VERBS.len()], + AREAS[(i / VERBS.len()) % AREAS.len()] + ); + let prompt = format!("{description} and report anything that breaks its invariants."); + events.push(AgentEvent::ToolCall { + id: fanout_id(i), + call: zeron_proto::ToolCall::Unknown { + name: format!("Agent: {description}"), + input: Some(serde_json::json!({ + "description": description, + "prompt": prompt, + })), + }, + }); + events.push(fanout_tag(i, AgentEvent::UserMessage { text: prompt })); + events.push(fanout_tag( + i, + AgentEvent::TextDelta { + text: "Reading the code paths involved.\n\n".into(), + }, + )); + } + events +} + +/// The `i`th scout's report and settle; every seventh fails. +fn fanout_settle(i: usize) -> Vec<AgentEvent> { + let failed = i % 7 == 6; + vec![ + fanout_tag( + i, + AgentEvent::TextDelta { + text: if failed { + "Could not reproduce the setup; giving up.".into() + } else { + "Invariants hold; nothing to report.".into() + }, + }, + ), + AgentEvent::ToolResult { + id: fanout_id(i), + is_error: failed, + output: None, + diff: None, + }, + fanout_tag( + i, + AgentEvent::Done { + status: if failed { + DoneStatus::Errored + } else { + DoneStatus::Completed + }, + result: None, + error: None, + session_id: None, + }, + ), + ] +} + /// The scripted question set for the `ZERON_MOCK_QUESTION` variant (exercises /// the QuestionPanel end-to-end: single-select page, multi-select page). fn question_script() -> Vec<UserInputQuestion> { @@ -100,7 +202,7 @@ impl Harness for MockHarness { async fn run( &self, - _request: RunRequest, + request: RunRequest, controls: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { // Optional pacing knob for demos/manual testing: `ZERON_MOCK_DELAY_MS` @@ -166,6 +268,49 @@ impl Harness for MockHarness { return Ok(stream.boxed()); } + // Dev/testing knob: a `/fanout N` prompt spawns N subagents at once, + // then settles them one by one, oldest first (every seventh fails) — + // the only data-side way to fill the session card's subagent lane, + // scroll it, and watch it drain into its finished fold. They settle + // `ZERON_MOCK_DELAY_MS` apart, 2.5s by default. + if let Some(count) = fanout_count(&request.prompt) { + let settle = if delay_ms == 0 { + std::time::Duration::from_millis(2500) + } else { + delay + }; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>(); + tokio::spawn(async move { + for event in fanout_spawns(count) { + if tx.send(event).is_err() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } + for i in 0..count { + tokio::time::sleep(settle).await; + for event in fanout_settle(i) { + if tx.send(event).is_err() { + return; + } + } + } + let _ = tx.send(AgentEvent::TextDelta { + text: format!("All {count} scouts reported back."), + }); + let _ = tx.send(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + }); + }); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|event| (Ok(event), rx)) + }); + return Ok(stream.boxed()); + } + // Dev/testing knob: `ZERON_MOCK_REPEAT=N` loops the script body N times // before the final Done — long single-reply streams for frame-cost / // smoothness measurement (the terminal `Done` is emitted exactly once, @@ -650,3 +795,34 @@ impl Harness for MockHarness { .boxed()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fanout_reads_its_count_from_the_prompt() { + assert_eq!(fanout_count("/fanout"), Some(40)); + assert_eq!(fanout_count(" /fanout 12 "), Some(12)); + assert_eq!(fanout_count("/fanout 9000"), Some(200)); + assert_eq!(fanout_count("/fanouts"), None); + assert_eq!(fanout_count("Walk me through the pipeline"), None); + } + + #[test] + fn fanout_spawns_every_scout_and_every_seventh_fails() { + let spawns = fanout_spawns(40) + .into_iter() + .filter(|event| matches!(event, AgentEvent::ToolCall { .. })) + .count(); + assert_eq!(spawns, 40); + let failed = (0..40) + .filter(|&i| { + fanout_settle(i) + .iter() + .any(|event| matches!(event, AgentEvent::ToolResult { is_error: true, .. })) + }) + .count(); + assert_eq!(failed, 5); + } +} diff --git a/scripts/dev-demo.sh b/scripts/dev-demo.sh index bdcc2d25c8..1917d14fdd 100755 --- a/scripts/dev-demo.sh +++ b/scripts/dev-demo.sh @@ -12,6 +12,10 @@ cd "$(dirname "$0")/.." DAEMON_DIR=/tmp/zeron-demo-daemon UI_DIR=/tmp/zeron-demo-ui IPC=27921 +# Demo projects live in ~/github when it exists, else in throwaway folders +# (a space must point at a real folder). +PROJECTS_DIR="$HOME/github" +[[ -d "$PROJECTS_DIR" ]] || PROJECTS_DIR=/tmp/zeron-demo-projects DELAY="" [[ "${1:-}" == "--slow" ]] && DELAY=350 @@ -34,11 +38,13 @@ probe() { cargo run -q -p zeron-rpc --example rpc_probe -- "ws://127.0.0.1:$IPC" if [[ ! -f "$DAEMON_DIR/.demo-seeded" ]]; then echo "▸ seeding demo chats" DEV=$(probe LocalDevice '{}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["deviceId"])') - # One space per demo folder, created up-front (chats join by space id). + # One space per demo folder, created up-front (chats join by space id); + # a folder already registered keeps its first space, so each is listed once. declare -A SPACES=() - for project in zeron soccertcg zeron aether; do + for project in zeron soccertcg aether; do sid=$(uuidgen | tr 'A-Z' 'a-z') - probe Mutate "{\"op\":\"createSpace\",\"spaceId\":\"$sid\",\"deviceId\":\"$DEV\",\"path\":\"$HOME/github/$project\"}" >/dev/null + mkdir -p "$PROJECTS_DIR/$project" + probe Mutate "{\"op\":\"createSpace\",\"spaceId\":\"$sid\",\"deviceId\":\"$DEV\",\"path\":\"$PROJECTS_DIR/$project\"}" >/dev/null SPACES[$project]="$sid" done seed() { # title project branch age_hours run @@ -61,5 +67,27 @@ if [[ ! -f "$DAEMON_DIR/.demo-seeded" ]]; then touch "$DAEMON_DIR/.demo-seeded" fi +# A session fanning out 40 subagents with side chats under it, for the +# session card's lanes. Each launch fans out again; settled ones fold away. +FANOUT_ID_FILE="$DAEMON_DIR/.fanout-chat" +if [[ ! -f "$FANOUT_ID_FILE" ]]; then + echo "▸ seeding the fan-out session" + DEV=$(probe LocalDevice '{}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["deviceId"])') + sid=$(uuidgen | tr 'A-Z' 'a-z') + mkdir -p "$PROJECTS_DIR/scouts" + probe Mutate "{\"op\":\"createSpace\",\"spaceId\":\"$sid\",\"deviceId\":\"$DEV\",\"path\":\"$PROJECTS_DIR/scouts\"}" >/dev/null + fanout=$(uuidgen | tr 'A-Z' 'a-z') + probe Mutate "{\"op\":\"createChat\",\"chatId\":\"$fanout\",\"spaceId\":\"$sid\",\"config\":{\"harness\":\"mock\",\"model\":\"fable-5\",\"reasoning\":null,\"sandbox\":\"workspace-write\"}}" >/dev/null + probe Mutate "{\"op\":\"renameChat\",\"chatId\":\"$fanout\",\"title\":\"Fan Out Forty Scouts\"}" >/dev/null + for title in "Investigate the cadence regression" "Draft the migration notes" "Review the relay retry budget"; do + side=$(uuidgen | tr 'A-Z' 'a-z') + probe Mutate "{\"op\":\"createChat\",\"chatId\":\"$side\",\"spaceId\":\"$sid\",\"parentChatId\":\"$fanout\",\"config\":{\"harness\":\"mock\",\"model\":\"fable-5\",\"reasoning\":null,\"sandbox\":\"workspace-write\"}}" >/dev/null + probe Mutate "{\"op\":\"renameChat\",\"chatId\":\"$side\",\"title\":\"$title\"}" >/dev/null + done + echo "$fanout" > "$FANOUT_ID_FILE" +fi +probe QueueCommand "{\"chatId\":\"$(cat "$FANOUT_ID_FILE")\",\"command\":{\"kind\":\"run\",\"messageId\":\"$(uuidgen)\",\"request\":{\"prompt\":\"/fanout 40\",\"model\":null,\"reasoning\":null,\"modelOptions\":{},\"cwd\":\"/tmp\",\"sandbox\":\"workspace-write\",\"autoApprove\":true,\"resume\":null}}}" >/dev/null + echo "▸ opening zeron (composer is live — type into it; --slow shows streaming)" +echo " 'Fan Out Forty Scouts' settles a subagent every 2.5s; send '/fanout 40' to replay" ZERON_DATA_DIR="$UI_DIR" ZERON_IPC_PORT=$IPC RUST_LOG=warn ./target/debug/zeron From c90ff64102aadaf5f185ceaf2d9e15b25132591a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Tue, 13 Oct 2026 19:00:35 -0300 Subject: [PATCH 09/17] Session card: side chats wear their harness mark Each side chat leads its title with its harness's brand mark, as the sidebar's session rows do. --- crates/ui/src/shell/session_lanes.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index b04df4063e..36627a9c8d 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -184,6 +184,8 @@ pub(super) struct ChildChatRow { pub time_ago: SharedString, /// The chat's linked pull request, drawn as the sidebar's badge. pub change_request: Option<zeron_proto::ChangeRequestSummary>, + /// The harness it runs on, drawn as the sidebar's brand mark. + pub harness: Option<zeron_proto::HarnessId>, activity: DateTime<Utc>, } @@ -207,6 +209,7 @@ pub(super) fn child_chat_rows( status: state.display_status_for(chat, now), time_ago: zeron_proto::view::format_time_ago(activity, now).into(), change_request: state.change_request_for_chat(chat).cloned(), + harness: chat.config.as_ref().map(|config| config.harness), activity, } }) @@ -543,6 +546,14 @@ impl Shell { }), ) .child(popover::picker_icon_slot(mark)) + // The harness mark leads the title, as on the sidebar's rows. + .children(row.harness.map(|harness| { + let (path, tint) = crate::pickers::harness_brand_icon(harness); + icon(path) + .size(px(13.0)) + .flex_none() + .text_color(tint.unwrap_or(theme.text_muted).opacity(0.8)) + })) .child(title) .when(revealing, |row| { let shell = cx.weak_entity(); From a1f1b4432ad21094b3f93eb7ad64a1219ec3dd0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Tue, 13 Oct 2026 22:00:23 -0300 Subject: [PATCH 10/17] Session card: the lanes card scrolls with the floating rail Once the lanes outgrow the card and scroll, the card shows the file tree's floating scrollbar: it appears on scroll, lingers, fades, holds under the pointer, and drags. `popover::rail_for` gives a view that already hosts a rail through `ScrollRailHost` (the shell, for the projects menu) the same treatment for another scroller. Subagents now list plainly, with no track between their marks; side chats keep theirs. --- crates/ui/src/popover.rs | 80 ++++++++++++++++++++++++++++ crates/ui/src/shell/session_lanes.rs | 57 ++++++++++++++------ 2 files changed, 120 insertions(+), 17 deletions(-) diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 3352cc8208..ff43d22b7c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2004,6 +2004,86 @@ pub fn rail<V: ScrollRailHost>( ) } +/// One handle-backed rail on a view that hosts several: where its state +/// lives and which scroller feeds it. [`ScrollRailHost`] spells this out for +/// a view's only rail; a view can implement that trait once, so its other +/// rails go through [`rail_for`] instead. +pub struct RailAccess<V> { + pub bar: fn(&mut V) -> &mut MenuScrollbarState, + pub scroll: fn(&V) -> ScrollHandle, +} + +impl<V> Clone for RailAccess<V> { + fn clone(&self) -> Self { + *self + } +} + +impl<V> Copy for RailAccess<V> {} + +/// [`rail`] for a rail named by `access`: the same activity note, hide +/// scheduling, visuals, and six pointer listeners, off its scroll handle. +pub fn rail_for<V: 'static>( + host: &mut V, + access: RailAccess<V>, + id: &'static str, + theme: &Theme, + cx: &mut Context<V>, +) -> Option<AnyElement> { + let scroll = (access.scroll)(host); + let bar = (access.bar)(host); + bar.note_scroll(&scroll); + let metrics = bar.metrics(&scroll)?; + schedule_scrollbar_hide(bar, cx); + let strip = (access.bar)(host).render_rail(theme, metrics)?; + Some( + strip + .id(id) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if (access.bar)(this).set_bar_hovered(*hovered) { + cx.notify(); + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _, cx| { + let scroll = (access.scroll)(this); + if (access.bar)(this).begin_press(&scroll, event.position.y) { + cx.stop_propagation(); + cx.notify(); + } + }), + ) + .on_drag(MenuScrollbarDrag, |_, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| MenuScrollbarDragGhost) + }) + .on_drag_move(cx.listener( + move |this, event: &gpui::DragMoveEvent<MenuScrollbarDrag>, _, cx| { + let scroll = (access.scroll)(this); + if (access.bar)(this).drag_to(&scroll, event.event.position.y) { + cx.notify(); + } + }, + )) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(move |this, _: &MouseUpEvent, _, cx| { + (access.bar)(this).end_press(); + cx.notify(); + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _: &MouseUpEvent, _, cx| { + (access.bar)(this).end_press(); + cx.notify(); + }), + ) + .into_any_element(), + ) +} + /// Rewind a handle-owned menu list to its top and drop its rail activity /// baseline in one step — the pair every "reopen this menu fresh" site must /// make. Skipping the offset half strands the list mid-scroll; skipping the diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 36627a9c8d..e921c7d1bb 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -2,9 +2,10 @@ //! runs alongside the open chat — its **side chats** (forks, and chats an //! agent spawned through the Zeron MCP server) and its **subagents** (the //! spawn chips of its transcript, with their live status). Each lane is a -//! section of session-card rows whose status marks sit on one thin track; -//! clicking a row opens it in the right pane. Settled subagents fold into one -//! "finished" row, closed until opened. +//! section of session-card rows; the side chats' status marks sit on one +//! thin track, the subagents list plainly. Clicking a row opens it in the +//! right pane. Settled subagents fold into one "finished" row, closed until +//! opened. use gpui::Hsla; use zeron_doc::{MessagePart, SubagentStatus}; @@ -24,14 +25,17 @@ const TRACK_CLEARANCE: f32 = 8.0; const INITIAL_ROWS: usize = 8; const PAGE_ROWS: usize = 10; -/// What the lanes card has unfolded or paged; it follows the open chat. -#[derive(Debug, Default)] +/// What the lanes card has unfolded or paged, and where it has scrolled; it +/// follows the open chat. +#[derive(Default)] pub(super) struct LaneState { chat_id: String, finished_open: bool, shown_chats: usize, shown_finished: usize, scroll: gpui::ScrollHandle, + /// The card's floating scrollbar, the file tree's treatment. + bar: popover::MenuScrollbarState, } impl LaneState { @@ -446,23 +450,24 @@ impl Shell { } else { Vec::new() }; - // Running, the fold, and what the fold opens share one track. - let lane = active.len() + usize::from(folded > 0) + finished.len(); - let mut i = 0; + // Subagents list plainly, with no track between their marks. for row in active { - let row = self.render_subagent_row(row, view, theme, cx); - rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); - i += 1; + rows.push( + self.render_subagent_row(row, view, theme, cx) + .into_any_element(), + ); } if folded > 0 { - let row = self.render_finished_fold(folded, failed, open, theme, cx); - rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); - i += 1; + rows.push( + self.render_finished_fold(folded, failed, open, theme, cx) + .into_any_element(), + ); } for row in finished { - let row = self.render_subagent_row(row, view, theme, cx); - rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); - i += 1; + rows.push( + self.render_subagent_row(row, view, theme, cx) + .into_any_element(), + ); } if hidden > 0 { rows.push(self.render_show_more( @@ -489,9 +494,21 @@ impl Shell { .flex_col() .gap(px(popover::MENU_GAP)) .children(rows); + // The rail shows only once the lanes outgrow the card and scroll. + let scrollbar = popover::rail_for( + self, + popover::RailAccess { + bar: |this: &mut Self| &mut this.session_lanes.bar, + scroll: |this: &Self| this.session_lanes.scroll.clone(), + }, + "session-lanes-scrollbar", + theme, + cx, + ); div() .id("session-lanes") .debug_selector(|| "session-lanes".into()) + .relative() .w(px(SESSION_CARD_WIDTH)) .max_h(px(max_height)) .min_h_0() @@ -503,7 +520,13 @@ impl Shell { .border_color(theme.border) .bg(popover::surface_bg(theme)) .when(floating && !theme.is_frost(), |card| card.shadow_lg()) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if this.session_lanes.bar.set_list_hovered(*hovered) { + cx.notify(); + } + })) .child(body) + .children(scrollbar) .into_any_element() } From a1071ca73aab8df80b1414fd271f4fe0588d19ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Tue, 13 Oct 2026 22:00:23 -0300 Subject: [PATCH 11/17] Demo: let a second demo pick its own state and port ZERON_DEMO_DIR and ZERON_DEMO_PORT run the demo beside one already open from another worktree; unset, it keeps /tmp/zeron-demo-* and :27921. --- scripts/dev-demo.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/dev-demo.sh b/scripts/dev-demo.sh index 1917d14fdd..0e9b2889ee 100755 --- a/scripts/dev-demo.sh +++ b/scripts/dev-demo.sh @@ -6,12 +6,15 @@ # scripts/dev-demo.sh --slow # pace mock streams (~10s) to watch streaming # # Everything lives under /tmp/zeron-demo-*; re-runs reuse it. Ctrl-C cleans up. +# A second demo beside a running one (another worktree's) picks its own +# state and port: ZERON_DEMO_DIR=/tmp/zeron-demo-b ZERON_DEMO_PORT=27931. set -euo pipefail cd "$(dirname "$0")/.." -DAEMON_DIR=/tmp/zeron-demo-daemon -UI_DIR=/tmp/zeron-demo-ui -IPC=27921 +DEMO_DIR="${ZERON_DEMO_DIR:-/tmp/zeron-demo}" +DAEMON_DIR="$DEMO_DIR-daemon" +UI_DIR="$DEMO_DIR-ui" +IPC="${ZERON_DEMO_PORT:-27921}" # Demo projects live in ~/github when it exists, else in throwaway folders # (a space must point at a real folder). PROJECTS_DIR="$HOME/github" From 54c21729025b1c0dfd54ca2aff30df60b7a31bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Tue, 13 Oct 2026 23:01:32 -0300 Subject: [PATCH 12/17] Session card: drop the side chats' track Side chats list plainly too; no line runs between any lane's marks. --- crates/ui/src/shell/session_lanes.rs | 49 ++++------------------------ 1 file changed, 7 insertions(+), 42 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index e921c7d1bb..68606d09d0 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -2,25 +2,15 @@ //! runs alongside the open chat — its **side chats** (forks, and chats an //! agent spawned through the Zeron MCP server) and its **subagents** (the //! spawn chips of its transcript, with their live status). Each lane is a -//! section of session-card rows; the side chats' status marks sit on one -//! thin track, the subagents list plainly. Clicking a row opens it in the -//! right pane. Settled subagents fold into one "finished" row, closed until -//! opened. +//! section of session-card rows; clicking a row opens it in the right pane. +//! Settled subagents fold into one "finished" row, closed until opened. -use gpui::Hsla; use zeron_doc::{MessagePart, SubagentStatus}; use zeron_proto::{Chat, ChatIndicator}; use super::session_info::SESSION_CARD_WIDTH; use super::*; -/// The track runs through the rows' icon slots (the picker row's 8px pad -/// plus half the 16px slot), between their marks. -const TRACK_X: f32 = 15.5; -/// The middle of a picker row, where its mark sits. -const ROW_MID: f32 = popover::PICKER_ROW_HEIGHT / 2.0; -/// How far the track stops short of a mark's center. -const TRACK_CLEARANCE: f32 = 8.0; /// Rows a lane shows before "Show more" pages it, and the page size. const INITIAL_ROWS: usize = 8; const PAGE_ROWS: usize = 10; @@ -231,30 +221,6 @@ pub(super) fn child_chat_title(chat: &Chat) -> String { .unwrap_or_else(|| "New side chat".into()) } -/// A lane's track: the 1px rule through its rows' icon slots, broken -/// around each status mark like a line between stations. `first` and `last` -/// end it at the lane's first and last marks. -fn track( - row: gpui::Stateful<gpui::Div>, - first: bool, - last: bool, - color: Hsla, -) -> gpui::Stateful<gpui::Div> { - let segment = || div().absolute().left(px(TRACK_X)).w(px(1.0)).bg(color); - row.relative() - .when(!first, |row| { - // Up through the gap to the row above. - row.child( - segment() - .top(px(-popover::MENU_GAP)) - .h(px(ROW_MID - TRACK_CLEARANCE + popover::MENU_GAP)), - ) - }) - .when(!last, |row| { - row.child(segment().top(px(ROW_MID + TRACK_CLEARANCE)).bottom_0()) - }) -} - /// A status mark in a row's icon slot: Working spins, Completed wears the /// check, the rest a dot in the status color. fn status_mark( @@ -378,7 +344,6 @@ impl Shell { SubagentLane::new(subagent_rows(state, &chat.id)), ) }; - let rail = theme.text_muted.opacity(0.25); let view = cx.entity_id(); let mut rows: Vec<AnyElement> = vec![popover::menu_heading(theme, "Side chats").into_any_element()]; @@ -386,10 +351,11 @@ impl Shell { // ── Side chats, then the two ways to start one. let shown = LaneState::shown(self.session_lanes.shown_chats); let hidden = side_chats.len().saturating_sub(shown); - let lane = side_chats.len().min(shown); - for (i, row) in side_chats.into_iter().take(shown).enumerate() { - let row = self.render_side_chat_row(row, view, theme, cx); - rows.push(track(row, i == 0, i + 1 == lane, rail).into_any_element()); + for row in side_chats.into_iter().take(shown) { + rows.push( + self.render_side_chat_row(row, view, theme, cx) + .into_any_element(), + ); } if hidden > 0 { rows.push(self.render_show_more( @@ -450,7 +416,6 @@ impl Shell { } else { Vec::new() }; - // Subagents list plainly, with no track between their marks. for row in active { rows.push( self.render_subagent_row(row, view, theme, cx) From e70198099527e47503d4bfea7ac7a3106675614a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Wed, 14 Oct 2026 12:55:42 -0300 Subject: [PATCH 13/17] Session card: side chat starters move to the heading once branches exist --- crates/ui/src/shell/session_lanes.rs | 93 ++++++++++++++++++---------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 68606d09d0..8b87a7e378 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -345,10 +345,68 @@ impl Shell { ) }; let view = cx.entity_id(); - let mut rows: Vec<AnyElement> = - vec![popover::menu_heading(theme, "Side chats").into_any_element()]; + // ── The two ways to start a side chat: rows while the lane is empty, + // then icon buttons on the heading so the branches lead. + let busy = self.side_chat_creating; + let compact = !side_chats.is_empty(); + let mut heading = div() + .flex() + .items_center() + .child(div().flex_1().child(popover::menu_heading(theme, "Side chats"))); + let mut starters: Vec<AnyElement> = Vec::new(); + for (id, path, label, fork) in [ + ( + "session-info-side-chat", + icons::PLUS, + "New side chat", + false, + ), + ("session-info-fork", icons::FORK, "Fork chat", true), + ] { + let start = cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.close_session_info_overlay(cx); + if fork { + this.create_side_chat(cx); + } else { + this.create_child_chat(None, cx); + } + }); + if compact { + heading = heading.child( + div() + .id(id) + .debug_selector(move || id.into()) + .size(px(22.0)) + .flex_none() + .flex() + .items_center() + .justify_center() + .rounded(px(5.0)) + .cursor_pointer() + .when(busy, |button| button.opacity(0.5)) + .hover(|style| style.bg(crate::theme::ink(0.08))) + .tooltip(crate::settings::widgets::text_tooltip(label)) + .on_click(start) + .child(icon(path).size(px(14.0)).text_color(theme.text_muted)), + ); + } else { + starters.push( + popover::picker_row(theme, false, false, id) + .id(id) + .debug_selector(move || id.into()) + .when(busy, |row| row.opacity(0.5)) + .on_click(start) + .child(popover::picker_icon_slot(glyph(path, theme))) + .child(div().flex_1().child(label)) + .into_any_element(), + ); + } + } + let mut rows: Vec<AnyElement> = vec![heading.pr(px(2.0)).into_any_element()]; + rows.extend(starters); - // ── Side chats, then the two ways to start one. + // ── Side chats. let shown = LaneState::shown(self.session_lanes.shown_chats); let hidden = side_chats.len().saturating_sub(shown); for row in side_chats.into_iter().take(shown) { @@ -368,35 +426,6 @@ impl Shell { }, )); } - let busy = self.side_chat_creating; - for (id, path, label, fork) in [ - ( - "session-info-side-chat", - icons::PLUS, - "New side chat", - false, - ), - ("session-info-fork", icons::FORK, "Fork chat", true), - ] { - rows.push( - popover::picker_row(theme, false, false, id) - .id(id) - .debug_selector(move || id.into()) - .when(busy, |row| row.opacity(0.5)) - .on_click(cx.listener(move |this, _, _, cx| { - this.close_session_info_overlay(cx); - if fork { - this.create_side_chat(cx); - } else { - this.create_child_chat(None, cx); - } - })) - .child(popover::picker_icon_slot(glyph(path, theme))) - .child(div().flex_1().child(label)) - .into_any_element(), - ); - } - // ── Subagents: what runs rides the lane, the settled fold away. if !subagents.is_empty() { let failed = subagents.failed(); From 693153f9ab985883c31c5308e15550740b36c1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Wed, 14 Oct 2026 12:59:33 -0300 Subject: [PATCH 14/17] Session card: subagent rows carry the session's harness mark --- crates/ui/src/shell/session_lanes.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 8b87a7e378..da1c7f4b8d 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -255,6 +255,15 @@ fn glyph(path: &'static str, theme: &Theme) -> AnyElement { .into_any_element() } +/// The harness a row runs on, as the sidebar's brand mark. +fn harness_mark(harness: zeron_proto::HarnessId, theme: &Theme) -> gpui::Svg { + let (path, tint) = crate::pickers::harness_brand_icon(harness); + icon(path) + .size(px(13.0)) + .flex_none() + .text_color(tint.unwrap_or(theme.text_muted).opacity(0.8)) +} + /// A row's trailing time, in the muted subline size. fn time_label(text: SharedString, theme: &Theme) -> gpui::Div { div() @@ -345,6 +354,8 @@ impl Shell { ) }; let view = cx.entity_id(); + // Subagents run on the session's own harness. + let harness = chat.config.as_ref().map(|config| config.harness); // ── The two ways to start a side chat: rows while the lane is empty, // then icon buttons on the heading so the branches lead. let busy = self.side_chat_creating; @@ -447,7 +458,7 @@ impl Shell { }; for row in active { rows.push( - self.render_subagent_row(row, view, theme, cx) + self.render_subagent_row(row, harness, view, theme, cx) .into_any_element(), ); } @@ -459,7 +470,7 @@ impl Shell { } for row in finished { rows.push( - self.render_subagent_row(row, view, theme, cx) + self.render_subagent_row(row, harness, view, theme, cx) .into_any_element(), ); } @@ -564,13 +575,7 @@ impl Shell { ) .child(popover::picker_icon_slot(mark)) // The harness mark leads the title, as on the sidebar's rows. - .children(row.harness.map(|harness| { - let (path, tint) = crate::pickers::harness_brand_icon(harness); - icon(path) - .size(px(13.0)) - .flex_none() - .text_color(tint.unwrap_or(theme.text_muted).opacity(0.8)) - })) + .children(row.harness.map(|harness| harness_mark(harness, theme))) .child(title) .when(revealing, |row| { let shell = cx.weak_entity(); @@ -606,6 +611,7 @@ impl Shell { fn render_subagent_row( &mut self, row: SubagentRow, + harness: Option<zeron_proto::HarnessId>, view: gpui::EntityId, theme: &Theme, cx: &mut Context<Self>, @@ -629,6 +635,7 @@ impl Shell { ); })) .child(popover::picker_icon_slot(mark)) + .children(harness.map(|harness| harness_mark(harness, theme))) .child(title) .child(time_label(time.into(), theme)) } From 0713428f128bac044ed253bedd9ea08683a4cd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Wed, 14 Oct 2026 13:30:36 -0300 Subject: [PATCH 15/17] Session card: drop the failed count from the finished fold --- crates/ui/src/shell/session_lanes.rs | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index da1c7f4b8d..296e34dd36 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -156,13 +156,6 @@ impl SubagentLane { Self { active, finished } } - pub fn failed(&self) -> usize { - self.finished - .iter() - .filter(|row| row.status == Some(SubagentStatus::Failed)) - .count() - } - pub fn is_empty(&self) -> bool { self.active.is_empty() && self.finished.is_empty() } @@ -439,7 +432,6 @@ impl Shell { } // ── Subagents: what runs rides the lane, the settled fold away. if !subagents.is_empty() { - let failed = subagents.failed(); rows.push(popover::picker_divider().into_any_element()); rows.push(popover::menu_heading(theme, "Subagents").into_any_element()); let SubagentLane { active, finished } = subagents; @@ -464,7 +456,7 @@ impl Shell { } if folded > 0 { rows.push( - self.render_finished_fold(folded, failed, open, theme, cx) + self.render_finished_fold(folded, open, theme, cx) .into_any_element(), ); } @@ -640,11 +632,10 @@ impl Shell { .child(time_label(time.into(), theme)) } - /// The settled subagents' fold: the count, with any failures called out. + /// The settled subagents' fold, with their count. fn render_finished_fold( &mut self, count: usize, - failed: usize, open: bool, theme: &Theme, cx: &mut Context<Self>, @@ -678,15 +669,6 @@ impl Shell { .text_color(theme.text_muted) .child(SharedString::from(format!("{count} finished"))), ) - .when(failed > 0, |row| { - row.child( - div() - .flex_none() - .text_size(crate::typography::ui_rems(11.0)) - .text_color(spaces::status_dot_color(ChatIndicator::Errored, theme)) - .child(SharedString::from(format!("{failed} failed"))), - ) - }) } /// "Show N more" under a paged lane, its label lined up with the rows'. @@ -888,7 +870,6 @@ mod tests { "main--sub--old-done" ] ); - assert_eq!(lane.failed(), 1); } #[test] From b591baf6bc692b7392c9672eee92975ae6259170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= <jsgrrchg@gmail.com> Date: Thu, 15 Oct 2026 20:19:46 -0300 Subject: [PATCH 16/17] Session card: the branch row shows the checkout's uncommitted +/- totals --- crates/ui/src/shell.rs | 3 + crates/ui/src/shell/session_info.rs | 118 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 235b356ff2..ecc63c0102 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2234,6 +2234,8 @@ pub struct Shell { session_info_last_reserve: f32, /// The lanes card's folds and pages. session_lanes: session_lanes::LaneState, + /// The working-tree diffs behind the card's branch-row totals. + session_diffs: session_info::SessionDiffs, files_content_tween: Option<WidthTween>, /// The last tracked frame: its chat, the window fit, and for each of /// `[sidebar, surface host, Files]` the width painted and the width its @@ -2683,6 +2685,7 @@ impl Shell { session_info_docked: false, session_info_last_reserve: 0.0, session_lanes: session_lanes::LaneState::default(), + session_diffs: session_info::SessionDiffs::default(), files_content_tween: None, painted_columns: None, fit_exits: [None; 3], diff --git a/crates/ui/src/shell/session_info.rs b/crates/ui/src/shell/session_info.rs index aa012d048e..f95aca16ce 100644 --- a/crates/ui/src/shell/session_info.rs +++ b/crates/ui/src/shell/session_info.rs @@ -32,6 +32,26 @@ pub(super) fn session_card_reserve(main: f32, transcript: f32) -> Option<f32> { (main - reserve - 2.0 * TRANSCRIPT_GUTTER >= transcript - 0.5).then_some(reserve) } +/// The checkouts' working-tree diffs, for the branch row's `+N −M`: the +/// Changes pane's `WatchCheckoutDiffs` stream, kept without the pane. +#[derive(Default)] +pub(super) struct SessionDiffs { + diffs: Vec<zeron_proto::CheckoutDiff>, + /// Device the running watch targets: `None` the connected engine, else + /// a remote chat's host (diffs are produced where the checkout lives). + target: Option<Option<String>>, + _watch: Option<Task<()>>, +} + +impl SessionDiffs { + /// The chat's uncommitted line totals; `None` while clean or unknown. + fn totals(&self, chat: &zeron_proto::Chat) -> Option<(u32, u32)> { + crate::changes::resolve_diff(&self.diffs, chat) + .map(|diff| (diff.additions, diff.deletions)) + .filter(|&(adds, dels)| adds + dels > 0) + } +} + /// A non-interactive card row: icon slot, label, optional trailing detail. fn info_row(theme: &Theme, glyph: AnyElement, label: SharedString) -> gpui::Div { div() @@ -55,6 +75,71 @@ fn row_glyph(path: &'static str, theme: &Theme) -> AnyElement { } impl Shell { + /// Watch the selected chat's host for checkout diffs (idempotent per + /// host); it retries every 2s while the stream fails or ends. + fn ensure_session_diffs(&mut self, cx: &mut Context<Self>) { + let (target, engine) = { + let state = self.state.read(cx); + let Some(chat) = state.selected_chat_row() else { + return; + }; + let device = chat.device_id.clone(); + let target = (state.local_device_id.as_deref() != Some(device.as_str())) + .then_some(device); + (target, state.engine().cloned()) + }; + if self.session_diffs.target.as_ref() == Some(&target) { + return; + } + // Engine still booting: the next render retries. + let Some(engine) = engine else { + return; + }; + self.session_diffs.diffs.clear(); + self.session_diffs.target = Some(target.clone()); + self.session_diffs._watch = Some(cx.spawn(async move |this, cx| { + loop { + let mut params = serde_json::Map::new(); + if let Some(target) = &target { + params.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.clone()), + ); + } + if let Ok(mut rx) = engine + .client() + .subscribe( + methods::WATCH_CHECKOUT_DIFFS, + serde_json::Value::Object(params), + ) + .await + { + while let Some(value) = rx.recv().await { + let alive = this.update(cx, |this, cx| { + // Repaint only when the open chat's totals move. + let chat = this.state.read(cx).selected_chat_row().cloned(); + let totals = |diffs: &SessionDiffs| { + chat.as_ref().and_then(|chat| diffs.totals(chat)) + }; + let before = totals(&this.session_diffs); + if crate::changes::apply_diff_frame( + &mut this.session_diffs.diffs, + value, + ) && totals(&this.session_diffs) != before + { + cx.notify(); + } + }); + if alive.is_err() { + return; + } + } + } + cx.background_executor().timer(Duration::from_secs(2)).await; + } + })); + } + /// The card's reserve at the main column's LIVE width (see /// [`session_card_reserve`]): while the sidebar or a pane animates, the /// settled width would move or hide the card ahead of the column. @@ -225,10 +310,43 @@ impl Shell { None => info_row(&theme, row_glyph(icons::HOME, &theme), "No project".into()) .into_any_element(), }; + self.ensure_session_diffs(cx); + let totals = self.session_diffs.totals(&chat); let branch_row = branch.map(|branch| { info_row(&theme, row_glyph(icons::GIT_BRANCH, &theme), branch.clone()) .id("session-info-branch") .tooltip(crate::settings::widgets::text_tooltip(branch)) + // What the checkout holds uncommitted; it opens the diff. + .children(totals.map(|(adds, dels)| { + let stat = |text: String, color: gpui::Hsla| { + div() + .font_family(theme.font_mono.clone()) + .text_size(px(11.0)) + .text_color(color) + .child(SharedString::from(text)) + }; + div() + .id("session-info-diff") + .debug_selector(|| "session-info-diff".into()) + .flex_none() + .flex() + .items_center() + .gap(px(6.0)) + .px(px(5.0)) + .mr(px(-5.0)) + .py(px(2.0)) + .rounded(px(5.0)) + .cursor_pointer() + .hover(|style| style.bg(crate::theme::ink(0.08))) + .tooltip(crate::settings::widgets::text_tooltip("Uncommitted changes")) + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.close_session_info_overlay(cx); + this.add_diff_surface(window, cx); + })) + .child(stat(format!("+{adds}"), crate::changes::add_color(&theme))) + .child(stat(format!("−{dels}"), crate::changes::del_color(&theme))) + })) }); // ── The project's run actions. From 7465f91e9544bd7a295505c219cff4f006824874 Mon Sep 17 00:00:00 2001 From: Wing <contact@winglee.io> Date: Sat, 17 Oct 2026 10:44:07 -0700 Subject: [PATCH 17/17] Session card: leave takeover to rename a side chat; stop the diff watch while hidden Renaming a side chat from a tab menu in takeover focused an editor in the zero-width transcript column, so keystrokes went to an unseen field. Leave takeover before revealing the lane. The branch row's checkout-diff stream also ran for as long as the app lived; drop it while the card is hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../ui/src/shell/panel_interaction_tests.rs | 29 +++++++++++++++++++ crates/ui/src/shell/session_info.rs | 2 ++ crates/ui/src/shell/session_lanes.rs | 5 ++++ 3 files changed, 36 insertions(+) diff --git a/crates/ui/src/shell/panel_interaction_tests.rs b/crates/ui/src/shell/panel_interaction_tests.rs index c4ecac86ac..b5cc0bd6f1 100644 --- a/crates/ui/src/shell/panel_interaction_tests.rs +++ b/crates/ui/src/shell/panel_interaction_tests.rs @@ -435,6 +435,35 @@ fn side_chat_menu_rename_keeps_the_editor_visible_without_opening_files(cx: &mut assert!(field.top() >= card.top() && field.bottom() <= card.bottom()); } +#[gpui::test] +fn side_chat_rename_leaves_takeover_to_show_its_editor(cx: &mut TestAppContext) { + let (_dir, shell, cx) = setup(cx); + change(&shell, cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id": "side", "title": "Side chat", "parentChatId": "a", + "deviceId": "local", "archived": false, "createdAt": Utc::now(), + })) + .unwrap(), + ); + cx.notify(); + }); + shell.toggle_right_pane(cx); + }); + settled(&shell, cx); + change(&shell, cx, |s, _, cx| s.toggle_right_pane_expand(cx)); + near(settled(&shell, cx).widths[3], 0.0); + + change(&shell, cx, |s, _, cx| s.open_rename_chat("side".into(), cx)); + let frame = settled(&shell, cx); + assert!(!shell.read_with(cx, |s, _| s.right_pane_expanded)); + assert!(frame.widths[3] > 0.0, "the transcript column must return"); + let field = cx.debug_bounds("session-lanes-chat-side-title-editor").unwrap(); + let card = cx.debug_bounds("session-lanes").unwrap(); + assert!(field.top() >= card.top() && field.bottom() <= card.bottom()); +} + fn check_side_chat_rename_scroll(cx: &mut TestAppContext, width: f32, reduced_motion: bool) { let (_dir, shell, cx) = setup(cx); cx.simulate_resize(gpui::size(px(width), px(900.0))); diff --git a/crates/ui/src/shell/session_info.rs b/crates/ui/src/shell/session_info.rs index f95aca16ce..6d56649294 100644 --- a/crates/ui/src/shell/session_info.rs +++ b/crates/ui/src/shell/session_info.rs @@ -253,6 +253,8 @@ impl Shell { cx: &mut Context<Self>, ) -> Option<AnyElement> { if reveal <= 0.001 { + // Nothing shows the totals: stop streaming every checkout's patch. + self.session_diffs = SessionDiffs::default(); return None; } let theme = Theme::of(cx).for_popup(); diff --git a/crates/ui/src/shell/session_lanes.rs b/crates/ui/src/shell/session_lanes.rs index 296e34dd36..6ade0eb405 100644 --- a/crates/ui/src/shell/session_lanes.rs +++ b/crates/ui/src/shell/session_lanes.rs @@ -294,6 +294,11 @@ impl Shell { return false; }; self.session_lanes.reveal_chat(&parent, index); + // Takeover hides the transcript column the card lives in; a rename + // field there would take keystrokes unseen. + if self.right_pane_expanded { + self.toggle_right_pane_expand(cx); + } self.show_session_info(cx); true }