From 69aa86b469d361b1faebb86d88fc2a5e6e613410 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 6 Feb 2026 16:09:42 -0500 Subject: [PATCH 1/9] Add visual test for multi-workspace sidebar panel Adds a visual test that renders the MultiWorkspace sidebar open with: - Two active workspaces (private-test-remote, zed) - Four recent projects (tiny-project, font-kit, ideas, tmp) Changes: - sidebar: Add test-support feature with set_test_recent_projects helper - zed Cargo.toml: Add sidebar/test-support to visual-tests feature - visual_test_runner: Add run_multi_workspace_sidebar_visual_tests --- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 11 + crates/zed/Cargo.toml | 1 + crates/zed/src/visual_test_runner.rs | 296 ++++++++++++++++++++++++++- 4 files changed, 307 insertions(+), 2 deletions(-) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index f052e76ed463af..6a52f4cbb2d4ab 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -13,6 +13,7 @@ path = "src/sidebar.rs" [features] default = [] +test-support = [] [dependencies] fs.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 645edafbe6d441..aa654b50d083b6 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -661,6 +661,17 @@ impl Sidebar { (entries, multi_workspace.active_workspace_index()) } + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_projects( + &self, + projects: Vec, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, _cx| { + picker.delegate.recent_projects = projects; + }); + } + fn queue_refresh( &mut self, multi_workspace: Entity, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9579f42d41d743..066f52ab622028 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -49,6 +49,7 @@ visual-tests = [ "language_model/test-support", "fs/test-support", "recent_projects/test-support", + "sidebar/test-support", "title_bar/test-support", ] diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index d93cb54d811ca4..60b3a09fe52b44 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -59,6 +59,7 @@ use { }, image::RgbaImage, project_panel::ProjectPanel, + recent_projects::RecentProjectEntry, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, std::{ @@ -70,7 +71,7 @@ use { }, util::ResultExt as _, watch, - workspace::{AppState, Workspace}, + workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -426,7 +427,24 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } - // Run Test 3: Agent Thread View tests + // Run Test 3: Multi-workspace sidebar visual tests + println!("\n--- Test 3: multi_workspace_sidebar ---"); + match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) { + Ok(TestResult::Passed) => { + println!("✓ multi_workspace_sidebar: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ multi_workspace_sidebar: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e); + failed += 1; + } + } + + // Run Test 4: Agent Thread View tests #[cfg(feature = "visual-tests")] { println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---"); @@ -2772,3 +2790,277 @@ fn run_tool_permissions_visual_tests( // Return success - we're just capturing screenshots, not comparing baselines Ok(TestResult::Passed) } + +#[cfg(target_os = "macos")] +fn run_multi_workspace_sidebar_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + // Create temporary directories to act as worktrees for active workspaces + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + + let workspace1_dir = canonical_temp.join("private-test-remote"); + let workspace2_dir = canonical_temp.join("zed"); + std::fs::create_dir_all(&workspace1_dir)?; + std::fs::create_dir_all(&workspace2_dir)?; + + // Create directories for recent projects (they must exist on disk for display) + let recent1_dir = canonical_temp.join("tiny-project"); + let recent2_dir = canonical_temp.join("font-kit"); + let recent3_dir = canonical_temp.join("ideas"); + let recent4_dir = canonical_temp.join("tmp"); + std::fs::create_dir_all(&recent1_dir)?; + std::fs::create_dir_all(&recent2_dir)?; + std::fs::create_dir_all(&recent3_dir)?; + std::fs::create_dir_all(&recent4_dir)?; + + // Enable the agent-v2 feature flag so multi-workspace is active + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + // Create both projects upfront so we can build both workspaces during + // window creation, before the MultiWorkspace entity exists. + // This avoids a re-entrant read panic that occurs when Workspace::new + // tries to access the window root (MultiWorkspace) while it's being updated. + let project1 = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let project2 = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let window_size = size(px(1280.0), px(800.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + // Open a MultiWorkspace window with both workspaces created at construction time + let multi_workspace_window: WindowHandle = cx + .update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| { + let workspace1 = cx.new(|cx| { + Workspace::new(None, project1.clone(), app_state.clone(), window, cx) + }); + let workspace2 = cx.new(|cx| { + Workspace::new(None, project2.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| { + let mut multi_workspace = MultiWorkspace::new(workspace1, cx); + multi_workspace.activate(workspace2, cx); + multi_workspace + }) + }, + ) + }) + .context("Failed to open MultiWorkspace window")?; + + cx.run_until_parked(); + + // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote" + let add_worktree1_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace1 = &multi_workspace.workspaces()[0]; + let project = workspace1.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace1_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 1")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree1_task) + .context("Failed to add worktree 1")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Add worktree to workspace 2 (index 1) so it shows as "zed" + let add_worktree2_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace2 = &multi_workspace.workspaces()[1]; + let project = workspace2.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace2_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 2")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree2_task) + .context("Failed to add worktree 2")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Switch to workspace 1 so it's highlighted as active (index 0) + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate_index(0, window, cx); + }) + .context("Failed to activate workspace 1")?; + + cx.run_until_parked(); + + // Create the sidebar and register it on the MultiWorkspace + let sidebar = multi_workspace_window + .update(cx, |_multi_workspace, window, cx| { + let multi_workspace_handle = cx.entity(); + cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) + }) + .context("Failed to create sidebar")?; + + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.register_sidebar(sidebar.clone(), window, cx); + }) + .context("Failed to register sidebar")?; + + cx.run_until_parked(); + + // Inject recent project entries into the sidebar. + // We update the sidebar entity directly (not through the MultiWorkspace window update) + // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't + // be inside a MultiWorkspace update when that happens. + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + let recent_projects = vec![ + RecentProjectEntry { + name: "tiny-project".into(), + full_path: recent1_dir.to_string_lossy().to_string().into(), + paths: vec![recent1_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "font-kit".into(), + full_path: recent2_dir.to_string_lossy().to_string().into(), + paths: vec![recent2_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "ideas".into(), + full_path: recent3_dir.to_string_lossy().to_string().into(), + paths: vec![recent3_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "tmp".into(), + full_path: recent4_dir.to_string_lossy().to_string().into(), + paths: vec![recent4_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + ]; + sidebar.set_test_recent_projects(recent_projects, cx); + }); + }); + + // Notify MultiWorkspace so the sidebar's observer fires queue_refresh, + // which will rebuild entries (including the recent projects we just set) + // in a deferred callback outside the MultiWorkspace update context. + multi_workspace_window + .update(cx, |_multi_workspace, _window, cx| { + cx.notify(); + }) + .context("Failed to notify multi workspace")?; + + cx.run_until_parked(); + + // Open the sidebar + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + // Let rendering settle + for _ in 0..10 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Refresh the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + + cx.run_until_parked(); + + // Capture: sidebar open with active workspaces and recent projects + let test_result = run_visual_test( + "multi_workspace_sidebar_open", + multi_workspace_window.into(), + cx, + update_baseline, + )?; + + // Clean up worktrees + multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + let worktree_ids: Vec<_> = + project.worktrees(cx).map(|wt| wt.read(cx).id()).collect(); + for id in worktree_ids { + project.remove_worktree(id, cx); + } + }); + } + }) + .log_err(); + + cx.run_until_parked(); + + // Close the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.remove_window(); + }) + .log_err(); + + cx.run_until_parked(); + + for _ in 0..15 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + Ok(test_result) +} From c9dc657cf315176cb584583116b64762eee01d06 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 6 Feb 2026 17:27:33 -0500 Subject: [PATCH 2/9] Show thread title and status in sidebar workspace entries - Add AgentThreadStatus (Running/Completed/Errored) and AgentThreadInfo to MultiWorkspace for per-workspace thread tracking - Render thread info below each active workspace in the sidebar: spinning LoadCircle for running, blue check for completed, red X for errored - Top-align folder icons in workspace entries - Add close workspace button (X) on hover for each workspace entry - Add remove_workspace method to MultiWorkspace - Subscribe to AcpThread events in AgentDiff to push real-time thread status updates (title, stopped, error) to MultiWorkspace - Update visual test with Completed and Running thread states --- crates/agent_ui/src/agent_diff.rs | 79 +++++++++++-- crates/sidebar/src/sidebar.rs | 147 +++++++++++++++++++----- crates/workspace/src/multi_workspace.rs | 74 +++++++++++- crates/workspace/src/workspace.rs | 5 +- crates/zed/src/visual_test_runner.rs | 29 +++-- 5 files changed, 288 insertions(+), 46 deletions(-) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 850822679d2828..8fb99441c653f6 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,5 +1,5 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; -use acp_thread::{AcpThread, AcpThreadEvent}; +use acp_thread::{AcpThread, AcpThreadEvent, ThreadStatus}; use action_log::ActionLogTelemetry; use agent_settings::AgentSettings; use anyhow::Result; @@ -31,8 +31,8 @@ use std::{ use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; use util::ResultExt; use workspace::{ - Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, - Workspace, + AgentThreadInfo, AgentThreadStatus, Item, ItemHandle, ItemNavHistory, MultiWorkspace, + ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -1206,6 +1206,60 @@ impl AgentDiff { }); } + fn push_thread_info_to_multi_workspace( + workspace: &WeakEntity, + thread: &Entity, + window: &mut Window, + cx: &mut App, + ) { + let Some(Some(multi_workspace)) = window.root::() else { + return; + }; + let Some(workspace_entity) = workspace.upgrade() else { + return; + }; + let thread_ref = thread.read(cx); + let title = thread_ref.title(); + let status = match thread_ref.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + }; + let entity_id = workspace_entity.entity_id(); + multi_workspace.update(cx, |mw, cx| { + if let Some(index) = mw.workspace_index_by_entity_id(entity_id) { + mw.set_workspace_thread_info(index, Some(AgentThreadInfo { title, status }), cx); + } + }); + } + + fn push_thread_error_to_multi_workspace( + workspace: &WeakEntity, + thread: &Entity, + window: &mut Window, + cx: &mut App, + ) { + let Some(Some(multi_workspace)) = window.root::() else { + return; + }; + let Some(workspace_entity) = workspace.upgrade() else { + return; + }; + let title = thread.read(cx).title(); + let entity_id = workspace_entity.entity_id(); + multi_workspace.update(cx, |mw, cx| { + if let Some(index) = mw.workspace_index_by_entity_id(entity_id) { + mw.set_workspace_thread_info( + index, + Some(AgentThreadInfo { + title, + status: AgentThreadStatus::Errored, + }), + cx, + ); + } + }); + } + fn register_active_thread_impl( &mut self, workspace: &WeakEntity, @@ -1213,6 +1267,8 @@ impl AgentDiff { window: &mut Window, cx: &mut Context, ) { + Self::push_thread_info_to_multi_workspace(workspace, &thread, window, cx); + let action_log = thread.read(cx).action_log().clone(); let action_log_subscription = cx.observe_in(&action_log, window, { @@ -1333,6 +1389,7 @@ impl AgentDiff { ) { match event { AcpThreadEvent::NewEntry => { + Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); if thread .read(cx) .entries() @@ -1352,14 +1409,18 @@ impl AgentDiff { self.update_reviewing_editors(workspace, window, cx); } } - AcpThreadEvent::Stopped - | AcpThreadEvent::Error - | AcpThreadEvent::LoadError(_) - | AcpThreadEvent::Refusal => { + AcpThreadEvent::Stopped => { + Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); + self.update_reviewing_editors(workspace, window, cx); + } + AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => { + Self::push_thread_error_to_multi_workspace(workspace, thread, window, cx); self.update_reviewing_editors(workspace, window, cx); } - AcpThreadEvent::TitleUpdated - | AcpThreadEvent::TokenUsageUpdated + AcpThreadEvent::TitleUpdated => { + Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); + } + AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequired | AcpThreadEvent::PromptCapabilitiesUpdated diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index aa654b50d083b6..56c6d6f4939dba 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -11,12 +11,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use theme::ActiveTheme; use ui::utils::TRAFFIC_LIGHT_PADDING; -use ui::{Divider, HighlightedLabel, ListItem, Tab, Tooltip, prelude::*}; +use ui::{CommonAnimationExt, Divider, HighlightedLabel, ListItem, Tab, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::ResultExt as _; use workspace::{ - CloseIntent, MultiWorkspace, NewWorkspaceInWindow, OpenOptions, OpenVisible, - Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, Workspace, + AgentThreadInfo, AgentThreadStatus, CloseIntent, MultiWorkspace, NewWorkspaceInWindow, + OpenOptions, OpenVisible, Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, + Workspace, }; const DEFAULT_WIDTH: Pixels = px(320.0); @@ -29,10 +30,16 @@ struct WorkspaceThreadEntry { index: usize, worktree_label: SharedString, full_path: SharedString, + thread_info: Option, } impl WorkspaceThreadEntry { - fn new(index: usize, workspace: &Entity, cx: &App) -> Self { + fn new( + index: usize, + workspace: &Entity, + thread_info: Option, + cx: &App, + ) -> Self { let workspace_ref = workspace.read(cx); let worktrees: Vec<_> = workspace_ref @@ -65,6 +72,7 @@ impl WorkspaceThreadEntry { index, worktree_label, full_path, + thread_info, } } } @@ -270,11 +278,11 @@ impl PickerDelegate for WorkspacePickerDelegate { fn set_selected_index( &mut self, - index: usize, + ix: usize, _window: &mut Window, _cx: &mut Context>, ) { - self.selected_index = index; + self.selected_index = ix; } fn can_select( @@ -307,13 +315,12 @@ impl PickerDelegate for WorkspacePickerDelegate { fn update_matches( &mut self, query: String, - _window: &mut Window, + window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let query = query.trim().to_string(); self.query = query.clone(); - let entries = self.entries.clone(); + if query.is_empty() { self.matches = entries .into_iter() @@ -334,11 +341,9 @@ impl PickerDelegate for WorkspacePickerDelegate { } let executor = cx.background_executor().clone(); - - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |picker, cx| { let matches = cx .background_spawn(async move { - // Only build fuzzy candidates from data entries, skipping separators let data_entries: Vec<(usize, &SidebarEntry)> = entries .iter() .enumerate() @@ -402,15 +407,22 @@ impl PickerDelegate for WorkspacePickerDelegate { }) .await; - this.update(cx, |this, _cx| { - let first_selectable = matches - .iter() - .position(|m| !matches!(m.entry, SidebarEntry::Separator(_))) - .unwrap_or(0); - this.delegate.matches = matches; - this.delegate.selected_index = first_selectable; - }) - .log_err(); + picker + .update_in(cx, |picker, _window, _cx| { + picker.delegate.matches = matches; + if picker.delegate.matches.is_empty() { + picker.delegate.selected_index = 0; + } else { + let first_selectable = picker + .delegate + .matches + .iter() + .position(|m| !matches!(m.entry, SidebarEntry::Separator(_))) + .unwrap_or(0); + picker.delegate.selected_index = first_selectable; + } + }) + .log_err(); }) } @@ -461,6 +473,30 @@ impl PickerDelegate for WorkspacePickerDelegate { } } + fn render_thread_status_icon( + workspace_index: usize, + status: &AgentThreadStatus, + ) -> AnyElement { + match status { + AgentThreadStatus::Running => Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Accent) + .with_keyed_rotate_animation( + SharedString::from(format!("workspace-{}-spinner", workspace_index)), + 3, + ) + .into_any_element(), + AgentThreadStatus::Completed => Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(Color::Accent) + .into_any_element(), + AgentThreadStatus::Errored => Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error) + .into_any_element(), + } + } + match entry { SidebarEntry::Separator(title) => Some( div() @@ -481,16 +517,70 @@ impl PickerDelegate for WorkspacePickerDelegate { let worktree_label = thread_entry.worktree_label.clone(); let full_path = thread_entry.full_path.clone(); let title = render_title(worktree_label.clone(), positions); + let thread_info = thread_entry.thread_info.clone(); + let workspace_index = thread_entry.index; + let multi_workspace = self.multi_workspace.clone(); + let workspace_count = self.multi_workspace.read(_cx).workspaces().len(); + + let close_button = if workspace_count > 1 { + Some( + IconButton::new( + SharedString::from(format!("close-workspace-{}", workspace_index)), + IconName::Close, + ) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Close Workspace")) + .on_click({ + let multi_workspace = multi_workspace.clone(); + move |_, window, cx| { + multi_workspace.update(cx, |mw, cx| { + mw.remove_workspace(workspace_index, window, cx); + }); + } + }), + ) + } else { + None + }; Some( ListItem::new(("workspace-item", thread_entry.index)) .toggle_state(selected) - .start_slot( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::XSmall), + .when_some(close_button, |item, button| item.end_hover_slot(button)) + .child( + h_flex() + .items_start() + .gap(DynamicSpacing::Base06.rems(&*_cx)) + .child( + div().pt(px(4.0)).child( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::XSmall), + ), + ) + .child(v_flex().overflow_hidden().child(title).when_some( + thread_info, + |this, info| { + this.child( + h_flex() + .gap_1() + .items_center() + .px_0p5() + .child(render_thread_status_icon( + workspace_index, + &info.status, + )) + .child( + Label::new(info.title) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + }, + )), ) - .child(title) .when(!full_path.is_empty(), |item| { item.tooltip(move |_, cx| { Tooltip::with_meta( @@ -656,7 +746,10 @@ impl Sidebar { .workspaces() .iter() .enumerate() - .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx)) + .map(|(index, workspace)| { + let thread_info = multi_workspace.workspace_thread_info(index).cloned(); + WorkspaceThreadEntry::new(index, workspace, thread_info, cx) + }) .collect(); (entries, multi_workspace.active_workspace_index()) } diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 0c332d8c99d447..22708e482a1163 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,13 +1,27 @@ use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Focusable, ManagedView, - MouseButton, Pixels, Render, Subscription, Window, actions, deferred, px, + MouseButton, Pixels, Render, SharedString, Subscription, Window, actions, deferred, px, }; use project::Project; +use std::collections::HashMap; use ui::prelude::*; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentThreadStatus { + Running, + Completed, + Errored, +} + +#[derive(Clone, Debug)] +pub struct AgentThreadInfo { + pub title: SharedString, + pub status: AgentThreadStatus, +} + use crate::{ DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations, }; @@ -82,6 +96,7 @@ pub struct MultiWorkspace { sidebar: Option>, sidebar_open: bool, _sidebar_subscription: Option, + workspace_thread_infos: HashMap, } impl MultiWorkspace { @@ -92,6 +107,7 @@ impl MultiWorkspace { sidebar: None, sidebar_open: false, _sidebar_subscription: None, + workspace_thread_infos: HashMap::new(), } } @@ -332,6 +348,62 @@ impl MultiWorkspace { self.activate(new_workspace, cx); self.focus_active_workspace(window, cx); } + + pub fn workspace_index_by_entity_id(&self, entity_id: EntityId) -> Option { + self.workspaces + .iter() + .position(|w| w.entity_id() == entity_id) + } + + pub fn set_workspace_thread_info( + &mut self, + index: usize, + info: Option, + cx: &mut Context, + ) { + match info { + Some(info) => { + self.workspace_thread_infos.insert(index, info); + } + None => { + self.workspace_thread_infos.remove(&index); + } + } + cx.notify(); + } + + pub fn workspace_thread_info(&self, index: usize) -> Option<&AgentThreadInfo> { + self.workspace_thread_infos.get(&index) + } + + pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() <= 1 || index >= self.workspaces.len() { + return; + } + + self.workspaces.remove(index); + + self.workspace_thread_infos.remove(&index); + let old_infos: HashMap = + self.workspace_thread_infos.drain().collect(); + for (old_index, info) in old_infos { + let new_index = if old_index > index { + old_index - 1 + } else { + old_index + }; + self.workspace_thread_infos.insert(new_index, info); + } + + if self.active_workspace_index >= self.workspaces.len() { + self.active_workspace_index = self.workspaces.len() - 1; + } else if self.active_workspace_index > index { + self.active_workspace_index -= 1; + } + + self.focus_active_workspace(window, cx); + cx.notify(); + } } impl Render for MultiWorkspace { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 14ed89b3bc6dd9..2f41cd2db6c8d9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -24,8 +24,9 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ - DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, NextWorkspaceInWindow, - PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, ToggleWorkspaceSidebar, + AgentThreadInfo, AgentThreadStatus, DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, + NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, + ToggleWorkspaceSidebar, }; pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 60b3a09fe52b44..455828a26092ac 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -71,7 +71,9 @@ use { }, util::ResultExt as _, watch, - workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, + workspace::{ + AgentThreadInfo, AgentThreadStatus, AppState, MultiWorkspace, Workspace, WorkspaceId, + }, zed_actions::OpenSettingsAt, }; @@ -2992,14 +2994,27 @@ fn run_multi_workspace_sidebar_visual_tests( }); }); - // Notify MultiWorkspace so the sidebar's observer fires queue_refresh, - // which will rebuild entries (including the recent projects we just set) - // in a deferred callback outside the MultiWorkspace update context. + // Set thread info on MultiWorkspace (the sidebar reads it from there during refresh) multi_workspace_window - .update(cx, |_multi_workspace, _window, cx| { - cx.notify(); + .update(cx, |multi_workspace, _window, cx| { + multi_workspace.set_workspace_thread_info( + 0, + Some(AgentThreadInfo { + title: "Refine thread view scrolling behavior".into(), + status: AgentThreadStatus::Completed, + }), + cx, + ); + multi_workspace.set_workspace_thread_info( + 1, + Some(AgentThreadInfo { + title: "Add line numbers option to FileEditBlock".into(), + status: AgentThreadStatus::Running, + }), + cx, + ); }) - .context("Failed to notify multi workspace")?; + .context("Failed to set thread info")?; cx.run_until_parked(); From 90b8d8c309e9c564f9a02395ad8a48a7cfe8a331 Mon Sep 17 00:00:00 2001 From: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:55:25 +0000 Subject: [PATCH 3/9] Autofix --- crates/sidebar/src/sidebar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 56c6d6f4939dba..6a99c04137854e 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -532,7 +532,7 @@ impl PickerDelegate for WorkspacePickerDelegate { .icon_color(Color::Muted) .tooltip(Tooltip::text("Close Workspace")) .on_click({ - let multi_workspace = multi_workspace.clone(); + let multi_workspace = multi_workspace; move |_, window, cx| { multi_workspace.update(cx, |mw, cx| { mw.remove_workspace(workspace_index, window, cx); From 0c54696df390455e84ec761e3a25c6f64923b2e0 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 6 Feb 2026 22:38:02 -0800 Subject: [PATCH 4/9] Move thread info tracking from MultiWorkspace into Sidebar - Remove AgentThreadInfo/AgentThreadStatus types and workspace_thread_infos HashMap from MultiWorkspace - Remove push_thread_info_to_multi_workspace and push_thread_error_to_multi_workspace from AgentDiff - Add acp_thread and agent_ui dependencies to sidebar crate - Sidebar now queries AgentPanel.active_agent_thread() directly to read thread title and status from AcpThread - Add AgentPanelEvent::ActiveViewChanged so sidebar subscribes to targeted events instead of observing all panel notifications - Sidebar subscribes to AcpThread entities (via cx.observe_in) for status changes and to AgentPanel events for thread switches - Move visual test thread info setup to sidebar.set_test_thread_info() --- Cargo.lock | 2 + crates/agent_ui/src/agent_diff.rs | 71 +------------ crates/agent_ui/src/agent_panel.rs | 9 +- crates/agent_ui/src/agent_ui.rs | 2 +- crates/sidebar/Cargo.toml | 2 + crates/sidebar/src/sidebar.rs | 126 ++++++++++++++++++++---- crates/workspace/src/multi_workspace.rs | 57 +---------- crates/workspace/src/workspace.rs | 5 +- crates/zed/src/visual_test_runner.rs | 32 +++--- 9 files changed, 140 insertions(+), 166 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4ce09b3a0a705..21ee0bb9c02466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15341,6 +15341,8 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "sidebar" version = "0.1.0" dependencies = [ + "acp_thread", + "agent_ui", "fs", "fuzzy", "gpui", diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 8fb99441c653f6..bb00be46bad837 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1,5 +1,5 @@ use crate::{Keep, KeepAll, OpenAgentDiff, Reject, RejectAll}; -use acp_thread::{AcpThread, AcpThreadEvent, ThreadStatus}; +use acp_thread::{AcpThread, AcpThreadEvent}; use action_log::ActionLogTelemetry; use agent_settings::AgentSettings; use anyhow::Result; @@ -31,8 +31,8 @@ use std::{ use ui::{CommonAnimationExt, IconButtonShape, KeyBinding, Tooltip, prelude::*, vertical_divider}; use util::ResultExt; use workspace::{ - AgentThreadInfo, AgentThreadStatus, Item, ItemHandle, ItemNavHistory, MultiWorkspace, - ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, + Workspace, item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -1206,60 +1206,6 @@ impl AgentDiff { }); } - fn push_thread_info_to_multi_workspace( - workspace: &WeakEntity, - thread: &Entity, - window: &mut Window, - cx: &mut App, - ) { - let Some(Some(multi_workspace)) = window.root::() else { - return; - }; - let Some(workspace_entity) = workspace.upgrade() else { - return; - }; - let thread_ref = thread.read(cx); - let title = thread_ref.title(); - let status = match thread_ref.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - }; - let entity_id = workspace_entity.entity_id(); - multi_workspace.update(cx, |mw, cx| { - if let Some(index) = mw.workspace_index_by_entity_id(entity_id) { - mw.set_workspace_thread_info(index, Some(AgentThreadInfo { title, status }), cx); - } - }); - } - - fn push_thread_error_to_multi_workspace( - workspace: &WeakEntity, - thread: &Entity, - window: &mut Window, - cx: &mut App, - ) { - let Some(Some(multi_workspace)) = window.root::() else { - return; - }; - let Some(workspace_entity) = workspace.upgrade() else { - return; - }; - let title = thread.read(cx).title(); - let entity_id = workspace_entity.entity_id(); - multi_workspace.update(cx, |mw, cx| { - if let Some(index) = mw.workspace_index_by_entity_id(entity_id) { - mw.set_workspace_thread_info( - index, - Some(AgentThreadInfo { - title, - status: AgentThreadStatus::Errored, - }), - cx, - ); - } - }); - } - fn register_active_thread_impl( &mut self, workspace: &WeakEntity, @@ -1267,8 +1213,6 @@ impl AgentDiff { window: &mut Window, cx: &mut Context, ) { - Self::push_thread_info_to_multi_workspace(workspace, &thread, window, cx); - let action_log = thread.read(cx).action_log().clone(); let action_log_subscription = cx.observe_in(&action_log, window, { @@ -1389,7 +1333,6 @@ impl AgentDiff { ) { match event { AcpThreadEvent::NewEntry => { - Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); if thread .read(cx) .entries() @@ -1410,17 +1353,13 @@ impl AgentDiff { } } AcpThreadEvent::Stopped => { - Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); self.update_reviewing_editors(workspace, window, cx); } AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => { - Self::push_thread_error_to_multi_workspace(workspace, thread, window, cx); self.update_reviewing_editors(workspace, window, cx); } - AcpThreadEvent::TitleUpdated => { - Self::push_thread_info_to_multi_workspace(workspace, thread, window, cx); - } - AcpThreadEvent::TokenUsageUpdated + AcpThreadEvent::TitleUpdated + | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequired | AcpThreadEvent::PromptCapabilitiesUpdated diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0575d365dff2bd..7bf48c06bf2de5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1021,6 +1021,7 @@ impl AgentPanel { ActiveView::Configuration | ActiveView::History { .. } => { if let Some(previous_view) = self.previous_view.take() { self.active_view = previous_view; + cx.emit(AgentPanelEvent::ActiveViewChanged); match &self.active_view { ActiveView::AgentThread { thread_view } => { @@ -1417,7 +1418,7 @@ impl AgentPanel { } } - pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> { + pub fn active_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::AgentThread { thread_view, .. } => thread_view .read(cx) @@ -1476,6 +1477,7 @@ impl AgentPanel { if focus { self.focus_handle(cx).focus(window, cx); } + cx.emit(AgentPanelEvent::ActiveViewChanged); } fn populate_recently_updated_menu_section( @@ -1748,7 +1750,12 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { AgentSettings::get_global(cx).dock.into() } +pub enum AgentPanelEvent { + ActiveViewChanged, +} + impl EventEmitter for AgentPanel {} +impl EventEmitter for AgentPanel {} impl Panel for AgentPanel { fn persistent_name() -> &'static str { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d98a6c76bc79b3..3bfd58ff4bb2de 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -49,7 +49,7 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6a52f4cbb2d4ab..2401ec8bbb9a71 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -16,6 +16,8 @@ default = [] test-support = [] [dependencies] +acp_thread.workspace = true +agent_ui.workspace = true fs.workspace = true fuzzy.workspace = true gpui.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6a99c04137854e..6f10edb30e5277 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,3 +1,5 @@ +use acp_thread::ThreadStatus; +use agent_ui::{AgentPanel, AgentPanelEvent}; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ @@ -7,6 +9,9 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use project::Event as ProjectEvent; use recent_projects::{RecentProjectEntry, get_recent_projects}; +#[cfg(any(test, feature = "test-support"))] +use std::collections::HashMap; + use std::path::{Path, PathBuf}; use std::sync::Arc; use theme::ActiveTheme; @@ -15,11 +20,22 @@ use ui::{CommonAnimationExt, Divider, HighlightedLabel, ListItem, Tab, Tooltip, use ui_input::ErasedEditor; use util::ResultExt as _; use workspace::{ - AgentThreadInfo, AgentThreadStatus, CloseIntent, MultiWorkspace, NewWorkspaceInWindow, - OpenOptions, OpenVisible, Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, - Workspace, + CloseIntent, MultiWorkspace, NewWorkspaceInWindow, OpenOptions, OpenVisible, + Sidebar as WorkspaceSidebar, SidebarEvent, ToggleWorkspaceSidebar, Workspace, }; +#[derive(Clone, Debug, PartialEq, Eq)] +enum AgentThreadStatus { + Running, + Completed, +} + +#[derive(Clone, Debug)] +struct AgentThreadInfo { + title: SharedString, + status: AgentThreadStatus, +} + const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); @@ -34,12 +50,8 @@ struct WorkspaceThreadEntry { } impl WorkspaceThreadEntry { - fn new( - index: usize, - workspace: &Entity, - thread_info: Option, - cx: &App, - ) -> Self { + fn new(index: usize, workspace: &Entity, cx: &App) -> Self { + let thread_info = Self::thread_info(workspace, cx); let workspace_ref = workspace.read(cx); let worktrees: Vec<_> = workspace_ref @@ -75,6 +87,18 @@ impl WorkspaceThreadEntry { thread_info, } } + + fn thread_info(workspace: &Entity, cx: &App) -> Option { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + let thread_ref = thread.read(cx); + let title = thread_ref.title(); + let status = match thread_ref.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + }; + Some(AgentThreadInfo { title, status }) + } } #[derive(Clone)] @@ -490,10 +514,6 @@ impl PickerDelegate for WorkspacePickerDelegate { .size(IconSize::XSmall) .color(Color::Accent) .into_any_element(), - AgentThreadStatus::Errored => Icon::new(IconName::XCircle) - .size(IconSize::XSmall) - .color(Color::Error) - .into_any_element(), } } @@ -648,6 +668,10 @@ pub struct Sidebar { picker: Entity>, _subscription: Subscription, _project_subscriptions: Vec, + _agent_panel_subscriptions: Vec, + _thread_subscriptions: Vec, + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap, _fetch_recent_projects: Task<()>, } @@ -700,6 +724,10 @@ impl Sidebar { picker, _subscription: subscription, _project_subscriptions: Vec::new(), + _agent_panel_subscriptions: Vec::new(), + _thread_subscriptions: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap::new(), _fetch_recent_projects: fetch_recent_projects, }; this.queue_refresh(this.multi_workspace.clone(), window, cx); @@ -739,18 +767,25 @@ impl Sidebar { } fn build_workspace_thread_entries( + &self, multi_workspace: &MultiWorkspace, cx: &App, ) -> (Vec, usize) { - let entries = multi_workspace + #[allow(unused_mut)] + let mut entries: Vec = multi_workspace .workspaces() .iter() .enumerate() - .map(|(index, workspace)| { - let thread_info = multi_workspace.workspace_thread_info(index).cloned(); - WorkspaceThreadEntry::new(index, workspace, thread_info, cx) - }) + .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx)) .collect(); + + #[cfg(any(test, feature = "test-support"))] + for (index, info) in &self.test_thread_infos { + if let Some(entry) = entries.get_mut(*index) { + entry.thread_info = Some(info.clone()); + } + } + (entries, multi_workspace.active_workspace_index()) } @@ -765,6 +800,57 @@ impl Sidebar { }); } + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_thread_info(&mut self, index: usize, title: SharedString, status: &str) { + let status = match status { + "running" => AgentThreadStatus::Running, + _ => AgentThreadStatus::Completed, + }; + self.test_thread_infos + .insert(index, AgentThreadInfo { title, status }); + } + + fn subscribe_to_agent_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .filter_map(|workspace| { + let agent_panel = workspace.read(cx).panel::(cx)?; + Some(cx.subscribe_in( + &agent_panel, + window, + |this, _, _event: &AgentPanelEvent, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }, + )) + }) + .collect() + } + + fn subscribe_to_threads( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .filter_map(|workspace| { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + Some(cx.observe_in(&thread, window, |this, _, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + })) + }) + .collect() + } + fn queue_refresh( &mut self, multi_workspace: Entity, @@ -773,8 +859,10 @@ impl Sidebar { ) { cx.defer_in(window, move |this, window, cx| { this._project_subscriptions = this.subscribe_to_projects(window, cx); + this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); + this._thread_subscriptions = this.subscribe_to_threads(window, cx); let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| { - Self::build_workspace_thread_entries(multi_workspace, cx) + this.build_workspace_thread_entries(multi_workspace, cx) }); this.picker.update(cx, |picker, cx| { picker.delegate.set_entries(entries, active_index, cx); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 22708e482a1163..b57d6ac2c43646 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,27 +1,13 @@ use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Focusable, ManagedView, - MouseButton, Pixels, Render, SharedString, Subscription, Window, actions, deferred, px, + MouseButton, Pixels, Render, Subscription, Window, actions, deferred, px, }; use project::Project; -use std::collections::HashMap; use ui::prelude::*; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AgentThreadStatus { - Running, - Completed, - Errored, -} - -#[derive(Clone, Debug)] -pub struct AgentThreadInfo { - pub title: SharedString, - pub status: AgentThreadStatus, -} - use crate::{ DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations, }; @@ -96,7 +82,6 @@ pub struct MultiWorkspace { sidebar: Option>, sidebar_open: bool, _sidebar_subscription: Option, - workspace_thread_infos: HashMap, } impl MultiWorkspace { @@ -107,7 +92,6 @@ impl MultiWorkspace { sidebar: None, sidebar_open: false, _sidebar_subscription: None, - workspace_thread_infos: HashMap::new(), } } @@ -349,33 +333,6 @@ impl MultiWorkspace { self.focus_active_workspace(window, cx); } - pub fn workspace_index_by_entity_id(&self, entity_id: EntityId) -> Option { - self.workspaces - .iter() - .position(|w| w.entity_id() == entity_id) - } - - pub fn set_workspace_thread_info( - &mut self, - index: usize, - info: Option, - cx: &mut Context, - ) { - match info { - Some(info) => { - self.workspace_thread_infos.insert(index, info); - } - None => { - self.workspace_thread_infos.remove(&index); - } - } - cx.notify(); - } - - pub fn workspace_thread_info(&self, index: usize) -> Option<&AgentThreadInfo> { - self.workspace_thread_infos.get(&index) - } - pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context) { if self.workspaces.len() <= 1 || index >= self.workspaces.len() { return; @@ -383,18 +340,6 @@ impl MultiWorkspace { self.workspaces.remove(index); - self.workspace_thread_infos.remove(&index); - let old_infos: HashMap = - self.workspace_thread_infos.drain().collect(); - for (old_index, info) in old_infos { - let new_index = if old_index > index { - old_index - 1 - } else { - old_index - }; - self.workspace_thread_infos.insert(new_index, info); - } - if self.active_workspace_index >= self.workspaces.len() { self.active_workspace_index = self.workspaces.len() - 1; } else if self.active_workspace_index > index { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2f41cd2db6c8d9..14ed89b3bc6dd9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -24,9 +24,8 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ - AgentThreadInfo, AgentThreadStatus, DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, - NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, - ToggleWorkspaceSidebar, + DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, NextWorkspaceInWindow, + PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, ToggleWorkspaceSidebar, }; pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 455828a26092ac..12dcdbc07a8e02 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -71,9 +71,7 @@ use { }, util::ResultExt as _, watch, - workspace::{ - AgentThreadInfo, AgentThreadStatus, AppState, MultiWorkspace, Workspace, WorkspaceId, - }, + workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -2994,27 +2992,21 @@ fn run_multi_workspace_sidebar_visual_tests( }); }); - // Set thread info on MultiWorkspace (the sidebar reads it from there during refresh) - multi_workspace_window - .update(cx, |multi_workspace, _window, cx| { - multi_workspace.set_workspace_thread_info( + // Set thread info directly on the sidebar for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, _cx| { + sidebar.set_test_thread_info( 0, - Some(AgentThreadInfo { - title: "Refine thread view scrolling behavior".into(), - status: AgentThreadStatus::Completed, - }), - cx, + "Refine thread view scrolling behavior".into(), + "completed", ); - multi_workspace.set_workspace_thread_info( + sidebar.set_test_thread_info( 1, - Some(AgentThreadInfo { - title: "Add line numbers option to FileEditBlock".into(), - status: AgentThreadStatus::Running, - }), - cx, + "Add line numbers option to FileEditBlock".into(), + "running", ); - }) - .context("Failed to set thread info")?; + }); + }); cx.run_until_parked(); From 99c95357df4953414ff1e03a72142aeb59fbde32 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 6 Feb 2026 23:33:29 -0800 Subject: [PATCH 5/9] Persist thread titles to KVP for recent projects display When an agent thread is active in a workspace, its title is now persisted to the KVP store under a single key ('sidebar-last-thread-titles') as a JSON map keyed by sorted worktree paths. When loading recent projects in the sidebar, thread titles are looked up from this map and displayed as a muted subtitle beneath the project name. - Add db and serde_json dependencies to sidebar crate - Add sorted_paths_key() helper for canonical path-based map keys - persist_thread_titles() writes on every queue_refresh when thread info changes - set_recent_projects() reads the map and populates recent_project_thread_titles - Extract render_project_row() shared by workspace and recent project entries - Add set_test_recent_project_thread_title() for visual test injection - Update visual test to show thread titles on two recent project entries --- Cargo.lock | 2 + crates/sidebar/Cargo.toml | 2 + crates/sidebar/src/sidebar.rs | 190 +++++++++++++++++++++------ crates/zed/src/visual_test_runner.rs | 16 +++ 4 files changed, 170 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21ee0bb9c02466..7564fc95bb69ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15343,12 +15343,14 @@ version = "0.1.0" dependencies = [ "acp_thread", "agent_ui", + "db", "fs", "fuzzy", "gpui", "picker", "project", "recent_projects", + "serde_json", "theme", "ui", "ui_input", diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 2401ec8bbb9a71..dce21865afd99d 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -18,8 +18,10 @@ test-support = [] [dependencies] acp_thread.workspace = true agent_ui.workspace = true +db.workspace = true fs.workspace = true fuzzy.workspace = true +serde_json.workspace = true gpui.workspace = true picker.workspace = true project.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6f10edb30e5277..7773b15233dc15 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,5 +1,6 @@ use acp_thread::ThreadStatus; use agent_ui::{AgentPanel, AgentPanelEvent}; +use db::kvp::KEY_VALUE_STORE; use fs::Fs; use fuzzy::StringMatchCandidate; use gpui::{ @@ -9,7 +10,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use project::Event as ProjectEvent; use recent_projects::{RecentProjectEntry, get_recent_projects}; -#[cfg(any(test, feature = "test-support"))] + use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -36,6 +37,8 @@ struct AgentThreadInfo { status: AgentThreadStatus, } +const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles"; + const DEFAULT_WIDTH: Pixels = px(320.0); const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); @@ -132,6 +135,7 @@ struct WorkspacePickerDelegate { /// All recent projects including what's filtered out of entries /// used to add unopened projects to entries on rebuild recent_projects: Vec, + recent_project_thread_titles: HashMap, matches: Vec, selected_index: usize, query: String, @@ -145,6 +149,7 @@ impl WorkspacePickerDelegate { active_workspace_index: 0, workspace_thread_count: 0, recent_projects: Vec::new(), + recent_project_thread_titles: HashMap::new(), matches: Vec::new(), selected_index: 0, query: String::new(), @@ -163,6 +168,17 @@ impl WorkspacePickerDelegate { } fn set_recent_projects(&mut self, recent_projects: Vec, cx: &App) { + self.recent_project_thread_titles.clear(); + if let Some(map) = read_thread_title_map() { + for entry in &recent_projects { + let path_key = sorted_paths_key(&entry.paths); + if let Some(title) = map.get(&path_key) { + self.recent_project_thread_titles + .insert(entry.full_path.clone(), title.clone().into()); + } + } + } + self.recent_projects = recent_projects; let workspace_threads: Vec = self @@ -517,6 +533,42 @@ impl PickerDelegate for WorkspacePickerDelegate { } } + fn render_project_row( + title: AnyElement, + thread_subtitle: Option, + status_icon: Option, + cx: &App, + ) -> Div { + h_flex() + .items_start() + .gap(DynamicSpacing::Base06.rems(cx)) + .child( + div().pt(px(4.0)).child( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::XSmall), + ), + ) + .child(v_flex().overflow_hidden().child(title).when_some( + thread_subtitle, + |this, subtitle| { + this.child( + h_flex() + .gap_1() + .items_center() + .px_0p5() + .when_some(status_icon, |this, icon| this.child(icon)) + .child( + Label::new(subtitle) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + }, + )) + } + match entry { SidebarEntry::Separator(title) => Some( div() @@ -564,43 +616,19 @@ impl PickerDelegate for WorkspacePickerDelegate { None }; + let (thread_subtitle, status_icon) = match thread_info { + Some(info) => ( + Some(info.title), + Some(render_thread_status_icon(workspace_index, &info.status)), + ), + None => (None, None), + }; + Some( ListItem::new(("workspace-item", thread_entry.index)) .toggle_state(selected) .when_some(close_button, |item, button| item.end_hover_slot(button)) - .child( - h_flex() - .items_start() - .gap(DynamicSpacing::Base06.rems(&*_cx)) - .child( - div().pt(px(4.0)).child( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::XSmall), - ), - ) - .child(v_flex().overflow_hidden().child(title).when_some( - thread_info, - |this, info| { - this.child( - h_flex() - .gap_1() - .items_center() - .px_0p5() - .child(render_thread_status_icon( - workspace_index, - &info.status, - )) - .child( - Label::new(info.title) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ), - ) - }, - )), - ) + .child(render_project_row(title, thread_subtitle, status_icon, _cx)) .when(!full_path.is_empty(), |item| { item.tooltip(move |_, cx| { Tooltip::with_meta( @@ -620,16 +648,15 @@ impl PickerDelegate for WorkspacePickerDelegate { let title = render_title(name.clone(), positions); let item_id: SharedString = format!("recent-project-{:?}", project_entry.workspace_id).into(); + let thread_title = self + .recent_project_thread_titles + .get(&project_entry.full_path) + .cloned(); Some( ListItem::new(item_id) .toggle_state(selected) - .start_slot( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::XSmall), - ) - .child(title) + .child(render_project_row(title, thread_title, None, _cx)) .tooltip(move |_, cx| { Tooltip::with_meta(name.clone(), None, full_path.clone(), cx) }) @@ -672,6 +699,8 @@ pub struct Sidebar { _thread_subscriptions: Vec, #[cfg(any(test, feature = "test-support"))] test_thread_infos: HashMap, + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap, _fetch_recent_projects: Task<()>, } @@ -728,6 +757,8 @@ impl Sidebar { _thread_subscriptions: Vec::new(), #[cfg(any(test, feature = "test-support"))] test_thread_infos: HashMap::new(), + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap::new(), _fetch_recent_projects: fetch_recent_projects, }; this.queue_refresh(this.multi_workspace.clone(), window, cx); @@ -810,6 +841,23 @@ impl Sidebar { .insert(index, AgentThreadInfo { title, status }); } + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_project_thread_title( + &mut self, + full_path: SharedString, + title: SharedString, + cx: &mut Context, + ) { + self.test_recent_project_thread_titles + .insert(full_path.clone(), title.clone()); + self.picker.update(cx, |picker, _cx| { + picker + .delegate + .recent_project_thread_titles + .insert(full_path, title); + }); + } + fn subscribe_to_agent_panels( &mut self, window: &mut Window, @@ -851,6 +899,48 @@ impl Sidebar { .collect() } + fn persist_thread_titles( + &self, + entries: &[WorkspaceThreadEntry], + multi_workspace: &Entity, + cx: &mut Context, + ) { + let mut map = read_thread_title_map().unwrap_or_default(); + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let mut changed = false; + + for (workspace, entry) in workspaces.iter().zip(entries.iter()) { + if let Some(ref info) = entry.thread_info { + let paths: Vec<_> = workspace + .read(cx) + .worktrees(cx) + .map(|wt| wt.read(cx).abs_path()) + .collect(); + if paths.is_empty() { + continue; + } + let path_key = sorted_paths_key(&paths); + let title = info.title.to_string(); + if map.get(&path_key) != Some(&title) { + map.insert(path_key, title); + changed = true; + } + } + } + + if changed { + if let Some(json) = serde_json::to_string(&map).log_err() { + cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp(LAST_THREAD_TITLES_KEY.into(), json) + .await + .log_err(); + }) + .detach(); + } + } + } + fn queue_refresh( &mut self, multi_workspace: Entity, @@ -864,6 +954,9 @@ impl Sidebar { let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| { this.build_workspace_thread_entries(multi_workspace, cx) }); + + this.persist_thread_titles(&entries, &multi_workspace, cx); + this.picker.update(cx, |picker, cx| { picker.delegate.set_entries(entries, active_index, cx); let query = picker.query(cx); @@ -890,6 +983,23 @@ impl Focusable for Sidebar { } } +fn sorted_paths_key>(paths: &[P]) -> String { + let mut sorted: Vec = paths + .iter() + .map(|p| p.as_ref().to_string_lossy().to_string()) + .collect(); + sorted.sort(); + sorted.join("\n") +} + +fn read_thread_title_map() -> Option> { + let json = KEY_VALUE_STORE + .read_kvp(LAST_THREAD_TITLES_KEY) + .log_err() + .flatten()?; + serde_json::from_str(&json).log_err() +} + impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let titlebar_height = ui::utils::platform_title_bar_height(window); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 12dcdbc07a8e02..801f6857095ba7 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -3008,6 +3008,22 @@ fn run_multi_workspace_sidebar_visual_tests( }); }); + // Set last-worked-on thread titles on some recent projects for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + sidebar.set_test_recent_project_thread_title( + recent1_dir.to_string_lossy().to_string().into(), + "Fix flaky test in CI pipeline".into(), + cx, + ); + sidebar.set_test_recent_project_thread_title( + recent2_dir.to_string_lossy().to_string().into(), + "Upgrade font rendering engine".into(), + cx, + ); + }); + }); + cx.run_until_parked(); // Open the sidebar From fa89d82dbdf44e4b2297d7340fe59e7f84843c0d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 6 Feb 2026 23:54:09 -0800 Subject: [PATCH 6/9] Re-emit ActiveViewChanged when AcpServerView finishes loading AgentPanel now observes the active AcpServerView entity. When the server connection completes and the view transitions from Loading to Connected, the AcpServerView calls cx.notify(). The AgentPanel's observation picks this up and re-emits AgentPanelEvent::ActiveViewChanged, which the sidebar subscribes to. This closes the timing gap where the sidebar would refresh before the AcpThread existed and never learn about it. --- crates/agent_ui/src/agent_panel.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7bf48c06bf2de5..3ac2cbaf0df6c1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -428,6 +428,7 @@ pub struct AgentPanel { focus_handle: FocusHandle, active_view: ActiveView, previous_view: Option, + _active_view_observation: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, @@ -646,6 +647,7 @@ impl AgentPanel { focus_handle: cx.focus_handle(), context_server_registry, previous_view: None, + _active_view_observation: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), @@ -1474,6 +1476,16 @@ impl AgentPanel { self.active_view = new_view; } + self._active_view_observation = match &self.active_view { + ActiveView::AgentThread { thread_view } => { + Some(cx.observe(thread_view, |_this, _, cx| { + cx.emit(AgentPanelEvent::ActiveViewChanged); + cx.notify(); + })) + } + _ => None, + }; + if focus { self.focus_handle(cx).focus(window, cx); } From 700985528a2085b0d15bf7d5e470e2ae30a3c0f3 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Sat, 7 Feb 2026 16:51:59 -0800 Subject: [PATCH 7/9] Add active thread serialization --- crates/agent_ui/src/acp.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 6 +- crates/agent_ui/src/agent_panel.rs | 278 +++++++++++++++++++++++-- 3 files changed, 259 insertions(+), 27 deletions(-) diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 904c9a6c7b7e38..f76e64b557e7ee 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -5,7 +5,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod thread_history; -mod thread_view; +pub(crate) mod thread_view; pub use mode_selector::ModeSelector; pub use model_selector::AcpModelSelector; diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 65a43a639f502c..7f9eaf0a06b21b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2989,18 +2989,18 @@ pub(crate) mod tests { } } - struct StubAgentServer { + pub(crate) struct StubAgentServer { connection: C, } impl StubAgentServer { - fn new(connection: C) -> Self { + pub(crate) fn new(connection: C) -> Self { Self { connection } } } impl StubAgentServer { - fn default_response() -> Self { + pub(crate) fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Default response".into()), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 3ac2cbaf0df6c1..ba3b9943713db2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -81,10 +81,50 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; -#[derive(Serialize, Deserialize, Debug)] +fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope + .read(&key) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +async fn save_serialized_panel( + workspace_id: workspace::WorkspaceId, + panel: SerializedAgentPanel, +) -> Result<()> { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope.write(key, serde_json::to_string(&panel)?).await?; + Ok(()) +} + +/// Migration: reads the original single-panel format stored under the +/// `"agent_panel"` KVP key before per-workspace keying was introduced. +fn read_legacy_serialized_panel() -> Option { + KEY_VALUE_STORE + .read_kvp(AGENT_PANEL_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] struct SerializedAgentPanel { width: Option, selected_agent: Option, + #[serde(default)] + last_active_thread: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SerializedActiveThread { + session_id: String, + agent_type: AgentType, + title: Option, + cwd: Option, } pub fn init(cx: &mut App) { @@ -446,18 +486,44 @@ pub struct AgentPanel { impl AgentPanel { fn serialize(&mut self, cx: &mut Context) { + let workspace_id = self + .workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten(); + + let Some(workspace_id) = workspace_id else { + return; + }; + let width = self.width; let selected_agent = self.selected_agent.clone(); + + let last_active_thread = self.active_agent_thread(cx).map(|thread| { + let thread = thread.read(cx); + let title = thread.title(); + SerializedActiveThread { + session_id: thread.session_id().0.to_string(), + agent_type: self.selected_agent.clone(), + title: if title.as_ref() != DEFAULT_THREAD_TITLE { + Some(title.to_string()) + } else { + None + }, + cwd: None, + } + }); + self.pending_serialization = Some(cx.background_spawn(async move { - KEY_VALUE_STORE - .write_kvp( - AGENT_PANEL_KEY.into(), - serde_json::to_string(&SerializedAgentPanel { - width, - selected_agent: Some(selected_agent), - })?, - ) - .await?; + save_serialized_panel( + workspace_id, + SerializedAgentPanel { + width, + selected_agent: Some(selected_agent), + last_active_thread, + }, + ) + .await?; anyhow::Ok(()) })); } @@ -473,16 +539,18 @@ impl AgentPanel { Ok(prompt_store) => prompt_store.await.ok(), Err(_) => None, }; - let serialized_panel = if let Some(panel) = cx - .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) }) - .await - .log_err() - .flatten() - { - serde_json::from_str::(&panel).log_err() - } else { - None - }; + let workspace_id = workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten(); + + let serialized_panel = cx + .background_spawn(async move { + workspace_id + .and_then(read_serialized_panel) + .or_else(read_legacy_serialized_panel) + }) + .await; let slash_commands = Arc::new(SlashCommandWorkingSet::default()); let text_thread_store = workspace @@ -501,15 +569,30 @@ impl AgentPanel { let panel = cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); - if let Some(serialized_panel) = serialized_panel { + if let Some(serialized_panel) = &serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); - if let Some(selected_agent) = serialized_panel.selected_agent { + if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } cx.notify(); }); } + + if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { + let agent_type = thread_info.agent_type.clone(); + let session_info = AgentSessionInfo { + session_id: acp::SessionId::new(thread_info.session_id), + cwd: thread_info.cwd, + title: thread_info.title.map(SharedString::from), + updated_at: None, + meta: None, + }; + panel.update(cx, |panel, cx| { + panel.selected_agent = agent_type; + panel.load_agent_thread(session_info, window, cx); + }); + } panel })?; @@ -1478,8 +1561,9 @@ impl AgentPanel { self._active_view_observation = match &self.active_view { ActiveView::AgentThread { thread_view } => { - Some(cx.observe(thread_view, |_this, _, cx| { + Some(cx.observe(thread_view, |this, _, cx| { cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); cx.notify(); })) } @@ -3301,3 +3385,151 @@ impl AgentPanel { self.active_thread_view() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::thread_view::tests::{StubAgentServer, init_test}; + use assistant_text_thread::TextThreadStore; + use feature_flags::FeatureFlagAppExt; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use project::Project; + use workspace::{MultiWorkspace, Workspace}; + + #[gpui::test] + async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + // --- Create a MultiWorkspace window with two workspaces --- + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs, [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); + multi_workspace.activate(workspace.clone(), cx); + workspace + }) + .unwrap(); + + workspace_a.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + workspace_b.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // --- Set up workspace A: width=300, with an active thread --- + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_a.update(cx, |panel, _cx| { + panel.width = Some(px(300.0)); + }); + + panel_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel_a.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "workspace A should have an active thread after connection" + ); + }); + + let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone()); + + // --- Set up workspace B: ClaudeCode, width=400, no active thread --- + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_b.update(cx, |panel, _cx| { + panel.width = Some(px(400.0)); + panel.selected_agent = AgentType::ClaudeCode; + }); + + // --- Serialize both panels --- + panel_a.update(cx, |panel, cx| panel.serialize(cx)); + panel_b.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + // --- Load fresh panels for each workspace and verify independent state --- + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel A load should succeed"); + cx.run_until_parked(); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel B load should succeed"); + cx.run_until_parked(); + + // Workspace A should restore its thread, width, and agent type + loaded_a.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(300.0)), + "workspace A width should be restored" + ); + assert_eq!( + panel.selected_agent, agent_type_a, + "workspace A agent type should be restored" + ); + assert!( + panel.active_thread_view().is_some(), + "workspace A should have its active thread restored" + ); + }); + + // Workspace B should restore its own width and agent type, with no thread + loaded_b.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(400.0)), + "workspace B width should be restored" + ); + assert_eq!( + panel.selected_agent, + AgentType::ClaudeCode, + "workspace B agent type should be restored" + ); + assert!( + panel.active_thread_view().is_none(), + "workspace B should have no active thread" + ); + }); + } +} From f9d04c9fa3d409dbc862b39ffb737783e9a1cb77 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Sat, 7 Feb 2026 17:29:17 -0800 Subject: [PATCH 8/9] fix some initialization bugs --- crates/sidebar/src/sidebar.rs | 47 +++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 7773b15233dc15..dfe7bed9aac06d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -53,8 +53,12 @@ struct WorkspaceThreadEntry { } impl WorkspaceThreadEntry { - fn new(index: usize, workspace: &Entity, cx: &App) -> Self { - let thread_info = Self::thread_info(workspace, cx); + fn new( + index: usize, + workspace: &Entity, + persisted_titles: &HashMap, + cx: &App, + ) -> Self { let workspace_ref = workspace.read(cx); let worktrees: Vec<_> = workspace_ref @@ -83,6 +87,18 @@ impl WorkspaceThreadEntry { .join("\n") .into(); + let thread_info = Self::thread_info(workspace, cx).or_else(|| { + if worktrees.is_empty() { + return None; + } + let path_key = sorted_paths_key(&worktrees); + let title = persisted_titles.get(&path_key)?; + Some(AgentThreadInfo { + title: SharedString::from(title.clone()), + status: AgentThreadStatus::Completed, + }) + }); + Self { index, worktree_label, @@ -802,12 +818,16 @@ impl Sidebar { multi_workspace: &MultiWorkspace, cx: &App, ) -> (Vec, usize) { + let persisted_titles = read_thread_title_map().unwrap_or_default(); + #[allow(unused_mut)] let mut entries: Vec = multi_workspace .workspaces() .iter() .enumerate() - .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx)) + .map(|(index, workspace)| { + WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx) + }) .collect(); #[cfg(any(test, feature = "test-support"))] @@ -868,14 +888,21 @@ impl Sidebar { workspaces .iter() .filter_map(|workspace| { - let agent_panel = workspace.read(cx).panel::(cx)?; - Some(cx.subscribe_in( - &agent_panel, - window, - |this, _, _event: &AgentPanelEvent, window, cx| { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + Some(cx.subscribe_in( + &agent_panel, + window, + |this, _, _event: &AgentPanelEvent, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }, + )) + } else { + // Panel hasn't loaded yet — observe the workspace so we + // re-subscribe once the panel appears on its dock. + Some(cx.observe_in(workspace, window, |this, _, window, cx| { this.queue_refresh(this.multi_workspace.clone(), window, cx); - }, - )) + })) + } }) .collect() } From f65fd361583ef164731daa9f6b4387371405db98 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Sat, 7 Feb 2026 18:47:11 -0800 Subject: [PATCH 9/9] Add jewels to sidebar --- Cargo.lock | 3 + .../src/platform_title_bar.rs | 18 +- crates/sidebar/Cargo.toml | 10 + crates/sidebar/src/sidebar.rs | 290 +++++++++++++++++- crates/title_bar/src/title_bar.rs | 19 +- crates/workspace/src/multi_workspace.rs | 12 + crates/zed/src/visual_test_runner.rs | 4 +- 7 files changed, 336 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7564fc95bb69ee..6c384e172d8d8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15344,6 +15344,8 @@ dependencies = [ "acp_thread", "agent_ui", "db", + "editor", + "feature_flags", "fs", "fuzzy", "gpui", @@ -15351,6 +15353,7 @@ dependencies = [ "project", "recent_projects", "serde_json", + "settings", "theme", "ui", "ui_input", diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 306c3325738f3e..8d63da7448f07b 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -30,6 +30,7 @@ pub struct PlatformTitleBar { should_move: bool, system_window_tabs: Entity, workspace_sidebar_open: bool, + sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -44,6 +45,7 @@ impl PlatformTitleBar { should_move: false, system_window_tabs, workspace_sidebar_open: false, + sidebar_has_notifications: false, } } @@ -74,8 +76,22 @@ impl PlatformTitleBar { self.workspace_sidebar_open } - pub fn set_workspace_sidebar_open(&mut self, open: bool) { + pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { self.workspace_sidebar_open = open; + cx.notify(); + } + + pub fn sidebar_has_notifications(&self) -> bool { + self.sidebar_has_notifications + } + + pub fn set_sidebar_has_notifications( + &mut self, + has_notifications: bool, + cx: &mut Context, + ) { + self.sidebar_has_notifications = has_notifications; + cx.notify(); } pub fn is_multi_workspace_enabled(cx: &App) -> bool { diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index dce21865afd99d..da4f29da820854 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -31,3 +31,13 @@ ui.workspace = true ui_input.workspace = true util.workspace = true workspace.workspace = true + +[dev-dependencies] +editor.workspace = true +feature_flags.workspace = true +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index dfe7bed9aac06d..fba2c7ec88cef9 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -11,7 +11,7 @@ use picker::{Picker, PickerDelegate}; use project::Event as ProjectEvent; use recent_projects::{RecentProjectEntry, get_recent_projects}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -26,7 +26,7 @@ use workspace::{ }; #[derive(Clone, Debug, PartialEq, Eq)] -enum AgentThreadStatus { +pub enum AgentThreadStatus { Running, Completed, } @@ -155,6 +155,7 @@ struct WorkspacePickerDelegate { matches: Vec, selected_index: usize, query: String, + notified_workspaces: HashSet, } impl WorkspacePickerDelegate { @@ -169,6 +170,7 @@ impl WorkspacePickerDelegate { matches: Vec::new(), selected_index: 0, query: String::new(), + notified_workspaces: HashSet::new(), } } @@ -178,6 +180,33 @@ impl WorkspacePickerDelegate { active_workspace_index: usize, cx: &App, ) { + let old_statuses: HashMap = self + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::WorkspaceThread(thread) => thread + .thread_info + .as_ref() + .map(|info| (thread.index, info.status.clone())), + _ => None, + }) + .collect(); + + for thread in &workspace_threads { + if let Some(info) = &thread.thread_info { + if info.status == AgentThreadStatus::Completed + && thread.index != active_workspace_index + { + if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { + self.notified_workspaces.insert(thread.index); + } + } + } + } + + if self.active_workspace_index != active_workspace_index { + self.notified_workspaces.remove(&active_workspace_index); + } self.active_workspace_index = active_workspace_index; self.workspace_thread_count = workspace_threads.len(); self.rebuild_entries(workspace_threads, cx); @@ -532,20 +561,28 @@ impl PickerDelegate for WorkspacePickerDelegate { fn render_thread_status_icon( workspace_index: usize, status: &AgentThreadStatus, + has_notification: bool, ) -> AnyElement { match status { AgentThreadStatus::Running => Icon::new(IconName::LoadCircle) .size(IconSize::XSmall) - .color(Color::Accent) + .color(Color::Muted) .with_keyed_rotate_animation( SharedString::from(format!("workspace-{}-spinner", workspace_index)), 3, ) .into_any_element(), - AgentThreadStatus::Completed => Icon::new(IconName::Check) - .size(IconSize::XSmall) - .color(Color::Accent) - .into_any_element(), + AgentThreadStatus::Completed => { + let color = if has_notification { + Color::Accent + } else { + Color::Muted + }; + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(color) + .into_any_element() + } } } @@ -632,10 +669,15 @@ impl PickerDelegate for WorkspacePickerDelegate { None }; + let has_notification = self.notified_workspaces.contains(&workspace_index); let (thread_subtitle, status_icon) = match thread_info { Some(info) => ( Some(info.title), - Some(render_thread_status_icon(workspace_index, &info.status)), + Some(render_thread_status_icon( + workspace_index, + &info.status, + has_notification, + )), ), None => (None, None), }; @@ -852,11 +894,12 @@ impl Sidebar { } #[cfg(any(test, feature = "test-support"))] - pub fn set_test_thread_info(&mut self, index: usize, title: SharedString, status: &str) { - let status = match status { - "running" => AgentThreadStatus::Running, - _ => AgentThreadStatus::Completed, - }; + pub fn set_test_thread_info( + &mut self, + index: usize, + title: SharedString, + status: AgentThreadStatus, + ) { self.test_thread_infos .insert(index, AgentThreadInfo { title, status }); } @@ -984,11 +1027,16 @@ impl Sidebar { this.persist_thread_titles(&entries, &multi_workspace, cx); + let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); this.picker.update(cx, |picker, cx| { picker.delegate.set_entries(entries, active_index, cx); let query = picker.query(cx); picker.update_matches(query, window, cx); }); + let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); + if had_notifications != has_notifications { + multi_workspace.update(cx, |_, cx| cx.notify()); + } }); } } @@ -1002,6 +1050,10 @@ impl WorkspaceSidebar for Sidebar { self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); cx.notify(); } + + fn has_notifications(&self, cx: &App) -> bool { + !self.picker.read(cx).delegate.notified_workspaces.is_empty() + } } impl Focusable for Sidebar { @@ -1081,3 +1133,215 @@ impl Render for Sidebar { .child(self.picker.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + use feature_flags::FeatureFlagAppExt as _; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + }); + } + + fn set_thread_info_and_refresh( + sidebar: &Entity, + multi_workspace: &Entity, + index: usize, + title: &str, + status: AgentThreadStatus, + cx: &mut gpui::VisualTestContext, + ) { + sidebar.update_in(cx, |s, _window, _cx| { + s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone()); + }); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + } + + fn has_notifications(sidebar: &Entity, cx: &mut gpui::VisualTestContext) -> bool { + sidebar.read_with(cx, |s, cx| s.has_notifications(cx)) + } + + #[gpui::test] + async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace and switch to it so workspace 0 is background. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "should have no notifications initially" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "Running status alone should not create a notification" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "Running → Completed transition should create a notification" + ); + } + + #[gpui::test] + async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Workspace 0 is the active workspace — thread completes while + // the user is already looking at it. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "should not notify for the workspace the user is already looking at" + ); + } + + #[gpui::test] + async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace so we can switch away and back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + // Switch to workspace 1 so workspace 0 becomes a background workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + // Thread on workspace 0 transitions Running → Completed while + // the user is looking at workspace 1. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "background workspace completion should create a notification" + ); + + // Switching back to workspace 0 should clear the notification. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "notification should be cleared when workspace becomes active" + ); + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 9643d86b00b0bd..67f30a840126dd 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -361,15 +361,19 @@ impl TitleBar { }; let is_open = multi_workspace.read(cx).is_sidebar_open(); - platform_titlebar.update(cx, |titlebar, _| { - titlebar.set_workspace_sidebar_open(is_open); + let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); }); let platform_titlebar = platform_titlebar.clone(); let subscription = cx.observe(&multi_workspace, move |mw, cx| { let is_open = mw.read(cx).is_sidebar_open(); - platform_titlebar.update(cx, |titlebar, _| { - titlebar.set_workspace_sidebar_open(is_open); + let has_notifications = mw.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); }); }); @@ -685,9 +689,16 @@ impl TitleBar { return None; } + let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); + Some( IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some(cx.theme().colors().title_bar_background)) + }) .tooltip(move |_, cx| { Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx) }) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index b57d6ac2c43646..deaa4d56cbb613 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -34,12 +34,14 @@ pub enum SidebarEvent { pub trait Sidebar: EventEmitter + Focusable + Render + Sized { fn width(&self, cx: &App) -> Pixels; fn set_width(&mut self, width: Option, cx: &mut Context); + fn has_notifications(&self, cx: &App) -> bool; } pub trait SidebarHandle: 'static + Send + Sync { fn width(&self, cx: &App) -> Pixels; fn set_width(&self, width: Option, cx: &mut App); fn focus(&self, window: &mut Window, cx: &mut App); + fn has_notifications(&self, cx: &App) -> bool; fn to_any(&self) -> AnyView; fn entity_id(&self) -> EntityId; } @@ -67,6 +69,10 @@ impl SidebarHandle for Entity { window.focus(&handle, cx); } + fn has_notifications(&self, cx: &App) -> bool { + self.read(cx).has_notifications(cx) + } + fn to_any(&self) -> AnyView { self.clone().into() } @@ -120,6 +126,12 @@ impl MultiWorkspace { self.sidebar_open && self.sidebar.is_some() } + pub fn sidebar_has_notifications(&self, cx: &App) -> bool { + self.sidebar + .as_ref() + .map_or(false, |s| s.has_notifications(cx)) + } + fn multi_workspace_enabled(&self, cx: &App) -> bool { cx.has_flag::() } diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 801f6857095ba7..0be1fb1f3d3329 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2998,12 +2998,12 @@ fn run_multi_workspace_sidebar_visual_tests( sidebar.set_test_thread_info( 0, "Refine thread view scrolling behavior".into(), - "completed", + sidebar::AgentThreadStatus::Completed, ); sidebar.set_test_thread_info( 1, "Add line numbers option to FileEditBlock".into(), - "running", + sidebar::AgentThreadStatus::Running, ); }); });