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, } +/// The subagent count a `/fanout [N]` prompt asks for (40 when bare). +fn fanout_count(prompt: &str) -> Option { + 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 { + 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 { + 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 { @@ -100,7 +202,7 @@ impl Harness for MockHarness { async fn run( &self, - _request: RunRequest, + request: RunRequest, controls: RunControls, ) -> Result>, 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::(); + 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/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 6ded093843..c1ee0f4872 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; @@ -254,24 +252,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)] @@ -347,8 +327,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, } @@ -377,9 +355,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-{}", @@ -399,7 +374,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)) @@ -674,9 +648,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 @@ -739,7 +710,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/popover.rs b/crates/ui/src/popover.rs index 3669c8499c..83988deb09 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2004,6 +2004,86 @@ pub fn rail( ) } +/// 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 { + pub bar: fn(&mut V) -> &mut MenuScrollbarState, + pub scroll: fn(&V) -> ScrollHandle, +} + +impl Clone for RailAccess { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for RailAccess {} + +/// [`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( + host: &mut V, + access: RailAccess, + id: &'static str, + theme: &Theme, + cx: &mut Context, +) -> Option { + 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, _, 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/running_pill.rs b/crates/ui/src/running_pill.rs index 46e7fa670d..1f7cc7903e 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,36 +246,27 @@ 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 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, count: u32, theme: &Theme, ) -> gpui::Stateful { 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() - .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.rs b/crates/ui/src/shell.rs index 07ca494117..ecc63c0102 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -84,6 +84,7 @@ mod panel_interaction_tests; pub(crate) mod project_icon; mod pull_request_pane; mod session_info; +mod session_lanes; mod side_chats; mod sidebar_pins; mod sidebar_sections; @@ -1459,11 +1460,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 @@ -2231,6 +2232,10 @@ 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, + /// The working-tree diffs behind the card's branch-row totals. + session_diffs: session_info::SessionDiffs, 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 @@ -2679,6 +2684,8 @@ impl Shell { session_info_pressed_open: None, 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], @@ -4091,14 +4098,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) } @@ -5814,7 +5813,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() { @@ -5836,28 +5835,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(); @@ -5897,7 +5895,6 @@ impl Shell { _events: events, _blur: None, }); - self.sync_explorer_chat_rename(cx); cx.notify(); } @@ -5922,7 +5919,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 @@ -5940,18 +5936,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) { @@ -10632,13 +10616,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/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 223e5a921e..59704665d9 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -332,34 +332,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/panel_interaction_tests.rs b/crates/ui/src/shell/panel_interaction_tests.rs index 84dded5ab0..b5cc0bd6f1 100644 --- a/crates/ui/src/shell/panel_interaction_tests.rs +++ b/crates/ui/src/shell/panel_interaction_tests.rs @@ -393,3 +393,136 @@ 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()); +} + +#[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))); + 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_info.rs b/crates/ui/src/shell/session_info.rs index ecc0b51c51..6d56649294 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; @@ -29,6 +32,26 @@ pub(super) fn session_card_reserve(main: f32, transcript: f32) -> Option { (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, + /// Device the running watch targets: `None` the connected engine, else + /// a remote chat's host (diffs are produced where the checkout lives). + target: Option>, + _watch: Option>, +} + +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() @@ -52,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) { + 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. @@ -101,9 +189,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(); @@ -154,6 +253,8 @@ impl Shell { cx: &mut Context, ) -> Option { 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(); @@ -211,38 +312,53 @@ 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))) + })) }); - // ── 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. + // 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") .max_h(px(max_height.max(120.0))) @@ -256,13 +372,14 @@ impl Shell { .child(device_row) .child(project_row) .children(branch_row) - .child(popover::picker_divider()) - .child(side_chat) - .child(fork) .children(actions); + // The session card keeps its height; only the lanes under it give. let card = div() .w(px(SESSION_CARD_WIDTH)) + .flex_shrink_0() + .flex() + .flex_col() .rounded(px(popover::CARD_RADIUS)) .overflow_hidden() .border_1() @@ -270,6 +387,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 +406,12 @@ impl Shell { .absolute() .top(px(Theme::TITLEBAR_HEIGHT + 12.0 + dy)) .right(px(SESSION_CARD_INSET - dx)) + // 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() + .gap(px(SESSION_CARDS_GAP)) .opacity(ease) .occlude() // Over the transcript it is a popover: a press elsewhere, or @@ -296,11 +421,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..6ade0eb405 --- /dev/null +++ b/crates/ui/src/shell/session_lanes.rs @@ -0,0 +1,920 @@ +//! 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; clicking a row opens it in the right pane. +//! Settled subagents fold into one "finished" row, closed until opened. + +use zeron_doc::{MessagePart, SubagentStatus}; +use zeron_proto::{Chat, ChatIndicator}; + +use super::session_info::SESSION_CARD_WIDTH; +use super::*; + +/// 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, 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 { + /// 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 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, + /// The harness it runs on, drawn as the sidebar's brand mark. + pub harness: 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(), + harness: chat.config.as_ref().map(|config| config.harness), + 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 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() +} + +/// 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() + .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() +} + +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); + // 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 + } + + /// 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, + cx: &mut Context, + ) { + 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. + pub(super) fn render_session_lanes( + &mut self, + chat: &Chat, + floating: bool, + max_height: f32, + 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 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; + 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 = 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 = vec![heading.pr(px(2.0)).into_any_element()]; + rows.extend(starters); + + // ── 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) { + rows.push( + self.render_side_chat_row(row, view, theme, cx) + .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; + }, + )); + } + // ── Subagents: what runs rides the lane, the settled fold away. + if !subagents.is_empty() { + rows.push(popover::picker_divider().into_any_element()); + 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); + 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() + }; + for row in active { + rows.push( + self.render_subagent_row(row, harness, view, theme, cx) + .into_any_element(), + ); + } + if folded > 0 { + rows.push( + self.render_finished_fold(folded, open, theme, cx) + .into_any_element(), + ); + } + for row in finished { + rows.push( + self.render_subagent_row(row, harness, view, theme, cx) + .into_any_element(), + ); + } + 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() + .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)) + .flex() + .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() + .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()) + .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() + } + + 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 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()), + }; + 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, _, _, cx| { + cx.stop_propagation(); + 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)) + // The harness mark leads the title, as on the sidebar's rows. + .children(row.harness.map(|harness| harness_mark(harness, theme))) + .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(), + 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, + harness: Option, + 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)) + .children(harness.map(|harness| harness_mark(harness, theme))) + .child(title) + .child(time_label(time.into(), theme)) + } + + /// The settled subagents' fold, with their count. + fn render_finished_fold( + &mut self, + count: 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"))), + ) + } + + /// "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" + ] + ); + } + + #[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 a989be9b7b..a74aba33f4 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -423,12 +423,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 = if on_canvas { None @@ -547,15 +559,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( @@ -603,6 +606,11 @@ impl Shell { cx.listener(|this, _, _, _| this.note_session_info_press()), ) .when(open, |button| button.bg(crate::theme::wash(0.09))) + // 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() .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 diff --git a/scripts/dev-demo.sh b/scripts/dev-demo.sh index bdcc2d25c8..0e9b2889ee 100755 --- a/scripts/dev-demo.sh +++ b/scripts/dev-demo.sh @@ -6,12 +6,19 @@ # 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" +[[ -d "$PROJECTS_DIR" ]] || PROJECTS_DIR=/tmp/zeron-demo-projects DELAY="" [[ "${1:-}" == "--slow" ]] && DELAY=350 @@ -34,11 +41,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 +70,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