@@ -0,0 +1,443 @@
+# Sidebar v2: Multi-Workspace Project Sidebar
+
+## Mental Model
+
+The sidebar is **not** a history view. It is the **window's workspace manager** β analogous to editor tabs, but for entire workspaces. Each OS window has its own sidebar with its own set of open projects/workspaces. This is ephemeral session state: started fresh per window, manually managed by the user. No cross-window synchronization.
+
+This means:
+- Opening a window gives you one workspace (your initial project).
+- You can add more workspaces to the window (open another project, create a new empty workspace, start a new thread in a new worktree).
+- The sidebar shows all open workspaces grouped by project, with their threads listed underneath.
+- Recent projects / history are **not** shown in the sidebar. That's a separate concern (file menu, command palette, etc.).
+
+## What the Screenshots Show
+
+### Left panel β the sidebar itself
+
+1. **Title bar**: "Threads" label, close button (left), new-thread button (right, `+` with gear βοΈ).
+
+2. **Project groups**: Threads are grouped by their worktree paths (the "project"). Each group has:
+ - A **header** showing the project folder names (e.g. `ex`, `ex, zed`, `zed`) with a colored sidebar indicator showing workspace associations.
+ - **Thread entries** underneath, each showing:
+ - Agent icon (Zed Agent, Claude, Codex CLI, etc.)
+ - Thread title (truncated with `...`)
+ - Optional: author name (e.g. `olivetti`, `rosewood`)
+ - Optional: diff stats (`+21 -12`)
+ - Timestamp (e.g. `5:45 PM`, `1d`, `3d`)
+ - A **"+ View More"** link at the bottom of groups with many threads.
+
+3. **Project group actions** (visible on hover / right-click):
+ - "Remove Project" (with keybinding)
+ - "Collapse Project" (with keybinding)
+
+4. **New Thread dropdown** (from `+` button):
+ - "New Thread in..."
+ - "Current Project"
+ - "New Worktree"
+
+5. **Agent picker** (dropdown from agent selector in toolbar):
+ - "Zed Agent" (default)
+ - "External Agents" section: Claude Code, Codex CLI, Gemini CLI, OpenCode
+ - "+ Add More Agents"
+
+6. **Search bar** at top for filtering threads.
+
+---
+
+## Current State (what exists today)
+
+The current `Sidebar` in `crates/sidebar/src/sidebar.rs` is a flat list using a `Picker` with `WorkspacePickerDelegate`. It has:
+
+- **`WorkspaceThreadEntry`**: One entry per workspace, showing worktree label + the active thread's title/status.
+- **`SidebarEntry`**: Either a separator, a workspace-thread entry, or a recent project.
+- **Recent projects**: Fetched from disk, shown below active workspaces, time-bucketed (Today, Yesterday, etc.).
+- **Notifications**: Tracks when background workspaces finish generating.
+- **Search**: Fuzzy matching across workspace names and recent project names.
+
+### Key gaps vs. the target design
+
+| Feature | Current | Target |
+|---------|---------|--------|
+| Data model | Flat list of workspaces | Flat `ListEntry` enum with project headers + threads |
+| Threads shown | Only the *active* thread per workspace | *All* threads for each project group |
+| Recent projects | Shown in sidebar | **Removed** from sidebar |
+| Grouping | None (flat list with separators) | By worktree paths (project headers in flat list) |
+| Thread source | `AgentPanel.active_thread_view()` | `ThreadStore.threads_for_paths()` for saved threads + active thread from `AgentPanel` |
+| Collapsible groups | No | Yes |
+| "View More" pagination | No | Yes (show N most recent, expand on click) |
+| Project actions | Remove workspace only | Remove project, collapse project |
+| New thread flow | Creates empty workspace | "New Thread in Current Project" / "New Worktree" |
+| Workspace color indicators | None | Colored vertical bars per workspace |
+| Rendering | `Picker` with `PickerDelegate` | `ListState` + `render_list_entry` (collab_panel pattern) |
+
+---
+
+## Implementation Plan
+
+### Phase 1: New Data Model & List Infrastructure
+
+**Goal**: Replace the current `WorkspaceThreadEntry` / `SidebarEntry` / `Picker` model with a flat `ListEntry` enum and `ListState`-based rendering, following the pattern established by `collab_panel.rs`. Remove recent projects from the sidebar entirely.
+
+The key insight from `collab_panel` is: **don't model the hierarchy in your data structures β model it in your `update_entries()` function**. You have a flat `Vec<ListEntry>` and a single `update_entries()` method that walks the data sources (workspaces, thread store) and pushes entries in the right order. The grouping is implicit in the push order.
+
+#### 1.1 Remove recent projects
+
+- Remove `RecentProjectEntry` from the entry enum.
+- Remove `recent_projects`, `recent_project_thread_titles`, `_fetch_recent_projects` from the sidebar.
+- Remove `get_recent_projects` dependency and time-bucketing logic (`TimeBucket`, etc.).
+- Remove `recent_projects` dependency from `Cargo.toml`.
+
+#### 1.2 Define a flat `ListEntry` enum
+
+Replace `SidebarEntry`, `WorkspaceThreadEntry`, `SidebarMatch`, and the `WorkspacePickerDelegate` with a single flat enum. Each variant carries all the data it needs to render, just like `collab_panel::ListEntry`:
+
+```rust
+enum ListEntry {
+ /// A project group header (e.g. "ex", "ex, zed", "zed").
+ /// Not selectable. Clicking toggles collapse.
+ ProjectHeader {
+ path_list: PathList,
+ label: SharedString,
+ },
+ /// A thread belonging to the project group above it.
+ Thread {
+ session_id: acp::SessionId,
+ title: SharedString,
+ icon: IconName,
+ status: AgentThreadStatus,
+ updated_at: DateTime<Utc>,
+ diff_stats: Option<(usize, usize)>,
+ /// If this thread is actively running in a workspace, which one.
+ workspace_index: Option<usize>,
+ },
+ /// "+ View More" link at the end of a project group.
+ ViewMore {
+ path_list: PathList,
+ remaining_count: usize,
+ },
+}
+```
+
+Auxiliary state lives on `Sidebar` itself, not in the entries:
+- `collapsed_groups: HashSet<PathList>` β which project groups are collapsed.
+- `expanded_groups: HashSet<PathList>` β which groups have "View More" expanded (default is collapsed to N items).
+- `selection: Option<usize>` β index into `entries`.
+- `entries: Vec<ListEntry>` β the flat list, rebuilt on every change.
+
+#### 1.3 Replace Picker with ListState-based rendering
+
+Drop the `Picker<WorkspacePickerDelegate>` entirely. Instead, follow the collab_panel pattern:
+
+- `Sidebar` owns a `ListState`, a `Vec<ListEntry>`, an optional `selection: Option<usize>`, and a search `Editor`.
+- Render with `list(self.list_state.clone(), cx.processor(Self::render_list_entry)).size_full()`.
+- `render_list_entry(&mut self, ix: usize, window, cx) -> AnyElement` matches on `self.entries[ix]` and dispatches to `render_project_header()`, `render_thread()`, `render_view_more()`.
+- Keyboard nav (`select_next`, `select_previous`, `confirm`) is implemented directly on `Sidebar` via action handlers, same as collab_panel.
+
+This gives us full-width rendering for every item (no picker chrome), collapsible headers, and direct control over the list.
+
+#### 1.4 Build the flat list in `update_entries()`
+
+A single `update_entries(&mut self, cx)` method (called whenever workspaces or threads change) rebuilds `self.entries` from scratch:
+
+1. Gather open workspaces from `MultiWorkspace.workspaces()`. For each, compute its `PathList` from worktree paths.
+2. For each workspace's `PathList`, query `ThreadStore::global(cx).threads_for_paths(&path_list)` to get saved threads for that project.
+3. List workspace groups in workspace creation order (i.e. their order in `MultiWorkspace.workspaces()`).
+4. For each workspace group:
+ - Push `ListEntry::ProjectHeader { path_list, label }` (always visible, even when collapsed).
+ - If not in `collapsed_groups`, push `ListEntry::Thread { ... }` for each thread (active thread from `AgentPanel` merged with saved threads from `threads_for_paths()`, deduped by session ID, sorted by `updated_at` descending).
+ - If there are more than N threads and the group isn't in `expanded_groups`, push only the first N threads then `ListEntry::ViewMore { remaining_count }`.
+5. Update `self.list_state` item count.
+
+This is the same imperative "walk and push" pattern as `collab_panel::update_entries`.
+
+#### 1.5 Subscribe to data sources
+
+Add subscriptions so `update_entries()` is called when data changes:
+- **`MultiWorkspace`** (already exists): workspace added/removed/activated.
+- **`ThreadStore::global(cx)`** (new): threads saved/deleted/reloaded.
+- **Per-workspace `AgentPanel`** (already exists): active thread changes, thread status changes.
+- **Per-workspace `Project`** (already exists): worktree added/removed (changes which group a workspace belongs to).
+
+#### 1.6 Tests for `update_entries()`
+
+Use visual snapshot tests following the `project_panel_tests.rs` pattern. Write a `visible_entries_as_strings` helper that reads `self.entries` and formats each `ListEntry` into a human-readable string, then assert against expected output.
+
+**Helper function**:
+
+The helper should show collapse state on project headers (`>` collapsed, `v` expanded), and selection state (`<== selected`) on any entry β mirroring the project panel pattern:
+
+```rust
+fn visible_entries_as_strings(
+ sidebar: &Entity<Sidebar>,
+ cx: &mut VisualTestContext,
+) -> Vec<String> {
+ sidebar.read_with(cx, |sidebar, _cx| {
+ sidebar.entries.iter().enumerate().map(|(ix, entry)| {
+ let selected = if sidebar.selection == Some(ix) {
+ " <== selected"
+ } else {
+ ""
+ };
+ match entry {
+ ListEntry::ProjectHeader { label, path_list, .. } => {
+ let icon = if sidebar.collapsed_groups.contains(path_list) {
+ ">"
+ } else {
+ "v"
+ };
+ format!("{} [{}]{}", icon, label, selected)
+ }
+ ListEntry::Thread { title, status, workspace_index, .. } => {
+ let active = if workspace_index.is_some() { " *" } else { "" };
+ let status_str = match status {
+ AgentThreadStatus::Running => " (running)",
+ AgentThreadStatus::Error => " (error)",
+ _ => "",
+ };
+ format!(" {}{}{}{}", title, active, status_str, selected)
+ }
+ ListEntry::ViewMore { remaining_count, .. } => {
+ format!(" + View More ({}){}", remaining_count, selected)
+ }
+ }
+ }).collect()
+ })
+}
+```
+
+**Test cases**:
+
+1. **Single workspace, no threads**:
+ ```
+ v [my-project]
+ ```
+
+2. **Single workspace with threads from ThreadStore**:
+ ```
+ v [my-project]
+ Fix crash in project panel
+ Add inline diff view
+ Build a task runner panel
+ ```
+
+3. **Multiple workspaces, each with their own threads**:
+ ```
+ v [project-a]
+ Thread A1 * (running)
+ Thread A2
+ v [project-b]
+ Thread B1
+ ```
+
+4. **View More when threads exceed N**:
+ ```
+ v [my-project]
+ Thread 1
+ Thread 2
+ Thread 3
+ Thread 4
+ Thread 5
+ + View More (7)
+ ```
+
+5. **Active thread from AgentPanel merged with saved threads**: the active thread appears in the list with `*` marker and is deduped against the ThreadStore copy.
+
+6. **Adding a workspace updates entries**: create a second workspace, assert it appears as a new project header with its threads.
+
+7. **Removing a workspace updates entries**: remove a workspace, assert its project header and threads are gone.
+
+8. **Worktree change updates group label**: add a folder to a workspace, assert the project header label updates (e.g. `v [ex]` β `v [ex, zed]`).
+
+### Phase 2: Rendering List Entries
+
+**Goal**: Implement the `render_list_entry` dispatcher and per-variant render methods.
+
+Each method returns a full-width `AnyElement`. No picker chrome, no indentation magic β each entry is a top-level item in the list, same as collab_panel.
+
+#### 2.1 Render project group headers
+
+Each `ProjectHeader` renders as:
+- The group label (derived from folder names, e.g. "ex, zed")
+- A collapse/expand chevron
+- On hover: action buttons (remove, collapse keybindings)
+
+Headers are not selectable (skipped by keyboard nav).
+
+#### 2.2 Render thread items
+
+Each thread renders using the existing `ThreadItem` component, which already supports:
+- Icon, title, timestamp
+- Diff stats (`.added()`, `.removed()`)
+- Running/completed/error status
+- Selected/hovered state
+- Action slot (for context menu or remove button)
+
+New additions needed for `ThreadItem`:
+- Author/branch name display (visible in screenshots as "olivetti", "rosewood")
+
+#### 2.3 Render "View More" items
+
+When a project group has more than N threads (e.g. 5), show only the N most recent and add a "+ View More" item. Clicking it adds the group's `PathList` to `self.expanded_groups` and calls `update_entries()`.
+
+#### 2.4 Implement collapse/expand
+
+Clicking a project group header or using the keybinding toggles membership in `self.collapsed_groups` and calls `update_entries()`. When collapsed, the group's threads and "View More" are not pushed into `entries` at all β the header is still visible.
+
+#### 2.5 Keyboard navigation and selection
+
+Implement directly on `Sidebar` (no Picker):
+- `select_next` / `select_previous` actions: move `self.selection`, skipping `ProjectHeader` entries.
+- `confirm` action: if selection is a `Thread`, activate its workspace or open it. If `ViewMore`, expand the group.
+- Track `selection: Option<usize>` and pass `is_selected` to render methods for highlighting.
+
+#### 2.6 Tests for collapse/expand, View More expansion, and selection
+
+Reuse `visible_entries_as_strings` from 1.6 β the `>` / `v` and `<== selected` markers make these behaviors directly assertable.
+
+1. **Collapsed group hides its threads**:
+ ```
+ > [project-a]
+ v [project-b]
+ Thread B1
+ ```
+
+2. **Expanding a collapsed group shows threads again**:
+ ```
+ v [project-a]
+ Thread A1
+ Thread A2
+ v [project-b]
+ Thread B1
+ ```
+
+3. **Expanding View More shows all threads**: start with `+ View More (7)`, click it, assert all 12 threads appear and "View More" is gone.
+
+4. **Selection skips headers**:
+ ```
+ v [project-a]
+ Thread A1 <== selected
+ Thread A2
+ v [project-b]
+ Thread B1
+ ```
+ After `select_next`:
+ ```
+ v [project-a]
+ Thread A1
+ Thread A2 <== selected
+ v [project-b]
+ Thread B1
+ ```
+ After `select_next` again (jumps over header):
+ ```
+ v [project-a]
+ Thread A1
+ Thread A2
+ v [project-b]
+ Thread B1 <== selected
+ ```
+
+5. **Confirm on selection activates workspace**: select a thread with `workspace_index: Some(1)`, confirm, assert `MultiWorkspace.active_workspace_index()` changed.
+
+### Phase 3: Project Group Actions
+
+**Goal**: Implement the context menu actions visible in the screenshots.
+
+#### 3.1 "Remove Project"
+
+- Removes all workspaces associated with this project group from the `MultiWorkspace`.
+- If there are no open workspaces for the group (it's only showing historical threads), this is a no-op or hides the group.
+- Keybinding: `Shift-Cmd-Backspace` (from screenshot)
+
+#### 3.2 "Collapse Project"
+
+- Toggles the collapsed state of the project group.
+- Keybinding: `Ctrl-Cmd-[` (from screenshot)
+
+#### 3.3 "New Thread" dropdown
+
+The `+` button in the header should show a popover/context menu:
+- **"Current Project"**: Creates a new thread in the currently active workspace's project. This means creating a new agent thread in the existing workspace's `AgentPanel`.
+- **"New Worktree"**: Creates a new empty workspace (existing `create_workspace` behavior) β prompts for a folder to open and starts a thread there.
+
+### Phase 5: Search
+
+**Goal**: The search behavior changes because we now search across thread titles (not just workspace names).
+
+#### 5.1 Update search candidates
+
+The fuzzy search should match against:
+- Thread titles
+- Project group labels (folder names)
+
+When filtering, show matching threads under their group headers. Hide groups with no matching threads.
+
+### Phase 6: Thread Lifecycle Integration
+
+**Goal**: Ensure the sidebar correctly reflects thread state changes in real time.
+
+#### 6.1 Live thread status
+
+For threads that are actively running in a workspace:
+- Subscribe to the workspace's `AgentPanel` events and the active `AcpThread` entity.
+- Update status (Running β Completed β Error) in real time.
+- The notification system (badge on sidebar toggle button) should continue working.
+
+#### 6.2 Thread saving
+
+When a thread is saved (via `ThreadStore.save_thread`), the `ThreadStore` reloads and notifies observers. The sidebar's `ThreadStore` subscription picks this up and rebuilds entries.
+
+#### 6.3 Thread switching within a workspace
+
+When the user switches threads within a workspace's `AgentPanel`, the sidebar should update to reflect which thread is active/selected.
+
+---
+
+## Execution Order & Dependencies
+
+```
+Phase 1 (Data Model & List Infrastructure)
+ βββ 1.1 Remove recent projects (pure deletion)
+ βββ 1.2 Define ListEntry enum
+ βββ 1.3 Replace Picker with ListState (depends on 1.2)
+ βββ 1.4 Build flat list in update_entries() (depends on 1.2)
+ βββ 1.5 Subscribe to data sources (depends on 1.4)
+ βββ 1.6 Tests for update_entries() (depends on 1.4, 1.5)
+
+Phase 2 (Rendering) β depends on Phase 1
+ βββ 2.1 Render project headers
+ βββ 2.2 Render thread items
+ βββ 2.3 Render "View More"
+ βββ 2.4 Collapse/expand
+ βββ 2.5 Keyboard navigation
+
+Phase 3 (Actions) β depends on Phase 2
+ βββ 3.1 Remove Project
+ βββ 3.2 Collapse Project
+ βββ 3.3 New Thread dropdown
+
+Phase 4 (Color Indicators) β depends on Phase 2
+Phase 5 (Search) β depends on Phase 2
+Phase 6 (Thread Lifecycle) β depends on Phase 1
+```
+
+Phases 1 and 6 are the most critical β they determine the data flow. Phases 2-3 are the UI work. Phases 4-5 are polish.
+
+## What's Explicitly Deferred
+
+- **Git worktree integration**: The "New Worktree" option in the new-thread dropdown hints at this, but the full git-worktree-based workflow (create branch β create worktree β open workspace) is a follow-up.
+- **Cross-window sidebar sync**: Explicitly not doing this. Each window manages its own sidebar state.
+- **Recent projects in sidebar**: Removed. Recent projects are accessible via command palette / file menu.
+- **Thread history browsing**: The sidebar shows threads for *open* project groups. Full thread history browsing is a separate feature.
+- **Collaborative features**: The "people" icon in the bottom toolbar is deferred.
+- **Bottom toolbar icons**: Exact functionality of the bottom icon row needs clarification β implement container only.
+
+## Files to Modify
+
+| File | Changes |
+|------|---------|
+| `crates/sidebar/src/sidebar.rs` | Major rewrite: `ListEntry` enum, `ListState` rendering, `update_entries()`, remove Picker |
+| `crates/sidebar/Cargo.toml` | Remove `recent_projects` dep, add `agent` dep (for `ThreadStore`, `DbThreadMetadata`) |
+| `crates/workspace/src/multi_workspace.rs` | Possibly add helper methods for project-group-level operations |
+| `crates/ui/src/components/ai/thread_item.rs` | May need author/branch name field |
+| `crates/agent/src/thread_store.rs` | May need additional query methods (e.g. threads grouped by path_list) |
@@ -1,711 +1,74 @@
use acp_thread::ThreadStatus;
+use agent::ThreadStore;
+use agent_client_protocol as acp;
use agent_ui::{AgentPanel, AgentPanelEvent};
-use chrono::{Datelike, Local, NaiveDate, TimeDelta};
-
-use fs::Fs;
-use fuzzy::StringMatchCandidate;
+use chrono::{DateTime, Utc};
use gpui::{
- App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString,
- Subscription, Task, Window, px,
+ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Pixels,
+ Render, SharedString, Subscription, Window, list, prelude::*, px,
};
-use picker::{Picker, PickerDelegate};
use project::Event as ProjectEvent;
-use recent_projects::{RecentProjectEntry, get_recent_projects};
-use std::fmt::Display;
-
use std::collections::{HashMap, HashSet};
-
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
use theme::ActiveTheme;
use ui::utils::TRAFFIC_LIGHT_PADDING;
-use ui::{
- AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip,
- prelude::*,
-};
-use ui_input::ErasedEditor;
-use util::ResultExt as _;
+use ui::{AgentThreadStatus, KeyBinding, Tooltip, prelude::*};
+use util::path_list::PathList;
use workspace::{
FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar,
SidebarEvent, ToggleWorkspaceSidebar, Workspace,
};
-#[derive(Clone, Debug)]
-struct AgentThreadInfo {
- title: SharedString,
- status: AgentThreadStatus,
- icon: IconName,
-}
-
const DEFAULT_WIDTH: Pixels = px(320.0);
const MIN_WIDTH: Pixels = px(200.0);
const MAX_WIDTH: Pixels = px(800.0);
-const MAX_MATCHES: usize = 100;
-
-#[derive(Clone)]
-struct WorkspaceThreadEntry {
- index: usize,
- worktree_label: SharedString,
- full_path: SharedString,
- thread_info: Option<AgentThreadInfo>,
-}
-
-impl WorkspaceThreadEntry {
- fn new(index: usize, workspace: &Entity<Workspace>, cx: &App) -> Self {
- let workspace_ref = workspace.read(cx);
-
- let worktrees: Vec<_> = workspace_ref
- .worktrees(cx)
- .filter(|worktree| worktree.read(cx).is_visible())
- .map(|worktree| worktree.read(cx).abs_path())
- .collect();
-
- let worktree_names: Vec<String> = worktrees
- .iter()
- .filter_map(|path| {
- path.file_name()
- .map(|name| name.to_string_lossy().to_string())
- })
- .collect();
-
- let worktree_label: SharedString = if worktree_names.is_empty() {
- format!("Workspace {}", index + 1).into()
- } else {
- worktree_names.join(", ").into()
- };
-
- let full_path: SharedString = worktrees
- .iter()
- .map(|path| path.to_string_lossy().to_string())
- .collect::<Vec<_>>()
- .join("\n")
- .into();
-
- let thread_info = Self::thread_info(workspace, cx);
-
- Self {
- index,
- worktree_label,
- full_path,
- thread_info,
- }
- }
-
- fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
- let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
- let agent_panel_ref = agent_panel.read(cx);
-
- let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx);
- let thread = thread_view.thread.read(cx);
-
- let icon = thread_view.agent_icon;
- let title = thread.title();
-
- let status = if thread.is_waiting_for_confirmation() {
- AgentThreadStatus::WaitingForConfirmation
- } else if thread.had_error() {
- AgentThreadStatus::Error
- } else {
- match thread.status() {
- ThreadStatus::Generating => AgentThreadStatus::Running,
- ThreadStatus::Idle => AgentThreadStatus::Completed,
- }
- };
- Some(AgentThreadInfo {
- title,
- status,
- icon,
- })
- }
-}
-
-#[derive(Clone)]
-enum SidebarEntry {
- Separator(SharedString),
- WorkspaceThread(WorkspaceThreadEntry),
- RecentProject(RecentProjectEntry),
-}
-
-impl SidebarEntry {
- fn searchable_text(&self) -> &str {
- match self {
- SidebarEntry::Separator(_) => "",
- SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(),
- SidebarEntry::RecentProject(entry) => entry.name.as_ref(),
- }
- }
-}
-
-#[derive(Clone)]
-struct SidebarMatch {
- entry: SidebarEntry,
- positions: Vec<usize>,
-}
-
-struct WorkspacePickerDelegate {
- multi_workspace: Entity<MultiWorkspace>,
- entries: Vec<SidebarEntry>,
- active_workspace_index: usize,
- workspace_thread_count: usize,
- /// All recent projects including what's filtered out of entries
- /// used to add unopened projects to entries on rebuild
- recent_projects: Vec<RecentProjectEntry>,
- recent_project_thread_titles: HashMap<SharedString, SharedString>,
- matches: Vec<SidebarMatch>,
- selected_index: usize,
- query: String,
- hovered_thread_item: Option<usize>,
- notified_workspaces: HashSet<usize>,
-}
-
-impl WorkspacePickerDelegate {
- fn new(multi_workspace: Entity<MultiWorkspace>) -> Self {
- Self {
- multi_workspace,
- entries: Vec::new(),
- 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(),
- hovered_thread_item: None,
- notified_workspaces: HashSet::new(),
- }
- }
-
- fn set_entries(
- &mut self,
- workspace_threads: Vec<WorkspaceThreadEntry>,
- active_workspace_index: usize,
- cx: &App,
- ) {
- if let Some(hovered_index) = self.hovered_thread_item {
- let still_exists = workspace_threads
- .iter()
- .any(|thread| thread.index == hovered_index);
- if !still_exists {
- self.hovered_thread_item = None;
- }
- }
-
- let old_statuses: HashMap<usize, AgentThreadStatus> = self
- .entries
- .iter()
- .filter_map(|entry| match entry {
- SidebarEntry::WorkspaceThread(thread) => thread
- .thread_info
- .as_ref()
- .map(|info| (thread.index, info.status)),
- _ => 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);
- }
-
- fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) {
- self.recent_project_thread_titles.clear();
-
- self.recent_projects = recent_projects;
-
- let workspace_threads: Vec<WorkspaceThreadEntry> = self
- .entries
- .iter()
- .filter_map(|entry| match entry {
- SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()),
- _ => None,
- })
- .collect();
- self.rebuild_entries(workspace_threads, cx);
- }
-
- fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> {
- self.multi_workspace
- .read(cx)
- .workspaces()
- .iter()
- .map(|workspace| {
- let mut paths = workspace.read(cx).root_paths(cx);
- paths.sort();
- paths
- })
- .collect()
- }
-
- fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) {
- let open_path_sets = self.open_workspace_path_sets(cx);
-
- self.entries.clear();
-
- if !workspace_threads.is_empty() {
- self.entries
- .push(SidebarEntry::Separator("Active Workspaces".into()));
- for thread in workspace_threads {
- self.entries.push(SidebarEntry::WorkspaceThread(thread));
- }
- }
-
- let recent: Vec<_> = self
- .recent_projects
- .iter()
- .filter(|project| {
- let mut project_paths: Vec<&Path> =
- project.paths.iter().map(|p| p.as_path()).collect();
- project_paths.sort();
- !open_path_sets.iter().any(|open_paths| {
- open_paths.len() == project_paths.len()
- && open_paths
- .iter()
- .zip(&project_paths)
- .all(|(a, b)| a.as_ref() == *b)
- })
- })
- .cloned()
- .collect();
+const DEFAULT_THREADS_SHOWN: usize = 5;
- if !recent.is_empty() {
- let today = Local::now().naive_local().date();
- let mut current_bucket: Option<TimeBucket> = None;
-
- for project in recent {
- let entry_date = project.timestamp.with_timezone(&Local).naive_local().date();
- let bucket = TimeBucket::from_dates(today, entry_date);
-
- if current_bucket != Some(bucket) {
- current_bucket = Some(bucket);
- self.entries
- .push(SidebarEntry::Separator(bucket.to_string().into()));
- }
-
- self.entries.push(SidebarEntry::RecentProject(project));
- }
- }
- }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum TimeBucket {
- Today,
- Yesterday,
- ThisWeek,
- PastWeek,
- All,
-}
-
-impl TimeBucket {
- fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
- if date == reference {
- return TimeBucket::Today;
- }
-
- if date == reference - TimeDelta::days(1) {
- return TimeBucket::Yesterday;
- }
-
- let week = date.iso_week();
-
- if reference.iso_week() == week {
- return TimeBucket::ThisWeek;
- }
-
- let last_week = (reference - TimeDelta::days(7)).iso_week();
-
- if week == last_week {
- return TimeBucket::PastWeek;
- }
-
- TimeBucket::All
- }
-}
-
-impl Display for TimeBucket {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- TimeBucket::Today => write!(f, "Today"),
- TimeBucket::Yesterday => write!(f, "Yesterday"),
- TimeBucket::ThisWeek => write!(f, "This Week"),
- TimeBucket::PastWeek => write!(f, "Past Week"),
- TimeBucket::All => write!(f, "All"),
- }
- }
-}
-
-fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) {
- let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
- return;
- };
-
- cx.defer(move |cx| {
- if let Some(task) = handle
- .update(cx, |multi_workspace, window, cx| {
- multi_workspace.open_project(paths, window, cx)
- })
- .log_err()
- {
- task.detach_and_log_err(cx);
- }
- });
+#[derive(Clone, Debug)]
+struct ActiveThreadInfo {
+ session_id: acp::SessionId,
+ title: SharedString,
+ status: AgentThreadStatus,
+ icon: IconName,
}
-impl PickerDelegate for WorkspacePickerDelegate {
- type ListItem = AnyElement;
-
- fn match_count(&self) -> usize {
- self.matches.len()
- }
-
- fn selected_index(&self) -> usize {
- self.selected_index
- }
-
- fn set_selected_index(
- &mut self,
- ix: usize,
- _window: &mut Window,
- _cx: &mut Context<Picker<Self>>,
- ) {
- self.selected_index = ix;
- }
-
- fn can_select(
- &mut self,
- ix: usize,
- _window: &mut Window,
- _cx: &mut Context<Picker<Self>>,
- ) -> bool {
- match self.matches.get(ix) {
- Some(SidebarMatch {
- entry: SidebarEntry::Separator(_),
- ..
- }) => false,
- _ => true,
- }
- }
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Searchβ¦".into()
- }
-
- fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- if self.query.is_empty() {
- None
- } else {
- Some("No threads match your search.".into())
- }
- }
-
- fn update_matches(
- &mut self,
- query: String,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- let query_changed = self.query != query;
- self.query = query.clone();
- if query_changed {
- self.hovered_thread_item = None;
- }
- let entries = self.entries.clone();
-
- if query.is_empty() {
- self.matches = entries
- .into_iter()
- .map(|entry| SidebarMatch {
- entry,
- positions: Vec::new(),
- })
- .collect();
-
- let separator_offset = if self.workspace_thread_count > 0 {
- 1
- } else {
- 0
- };
- self.selected_index = (self.active_workspace_index + separator_offset)
- .min(self.matches.len().saturating_sub(1));
- return Task::ready(());
- }
-
- let executor = cx.background_executor().clone();
- cx.spawn_in(window, async move |picker, cx| {
- let matches = cx
- .background_spawn(async move {
- let data_entries: Vec<(usize, &SidebarEntry)> = entries
- .iter()
- .enumerate()
- .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
- .collect();
-
- let candidates: Vec<StringMatchCandidate> = data_entries
- .iter()
- .enumerate()
- .map(|(candidate_index, (_, entry))| {
- StringMatchCandidate::new(candidate_index, entry.searchable_text())
- })
- .collect();
-
- let search_matches = fuzzy::match_strings(
- &candidates,
- &query,
- false,
- true,
- MAX_MATCHES,
- &Default::default(),
- executor,
- )
- .await;
-
- let mut workspace_matches = Vec::new();
- let mut project_matches = Vec::new();
-
- for search_match in search_matches {
- let (original_index, _) = data_entries[search_match.candidate_id];
- let entry = entries[original_index].clone();
- let sidebar_match = SidebarMatch {
- positions: search_match.positions,
- entry: entry.clone(),
- };
- match entry {
- SidebarEntry::WorkspaceThread(_) => {
- workspace_matches.push(sidebar_match)
- }
- SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
- SidebarEntry::Separator(_) => {}
- }
- }
-
- let mut result = Vec::new();
- if !workspace_matches.is_empty() {
- result.push(SidebarMatch {
- entry: SidebarEntry::Separator("Active Workspaces".into()),
- positions: Vec::new(),
- });
- result.extend(workspace_matches);
- }
- if !project_matches.is_empty() {
- result.push(SidebarMatch {
- entry: SidebarEntry::Separator("Recent Projects".into()),
- positions: Vec::new(),
- });
- result.extend(project_matches);
- }
- result
- })
- .await;
-
- 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();
- })
- }
-
- fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- let Some(selected_match) = self.matches.get(self.selected_index) else {
- return;
- };
-
- match &selected_match.entry {
- SidebarEntry::Separator(_) => {}
- SidebarEntry::WorkspaceThread(thread_entry) => {
- let target_index = thread_entry.index;
- self.multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate_index(target_index, window, cx);
- });
- }
- SidebarEntry::RecentProject(project_entry) => {
- let paths = project_entry.paths.clone();
- open_recent_project(paths, window, cx);
- }
- }
- }
-
- fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
-
- fn render_match(
- &self,
- index: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- let match_entry = self.matches.get(index)?;
- let SidebarMatch { entry, positions } = match_entry;
-
- match entry {
- SidebarEntry::Separator(title) => Some(
- v_flex()
- .when(index > 0, |this| {
- this.mt_1()
- .gap_2()
- .child(Divider::horizontal().color(DividerColor::BorderFaded))
- })
- .child(ListSubHeader::new(title.clone()).inset(true))
- .into_any_element(),
- ),
- SidebarEntry::WorkspaceThread(thread_entry) => {
- let worktree_label = thread_entry.worktree_label.clone();
- let full_path = thread_entry.full_path.clone();
- 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 is_hovered = self.hovered_thread_item == Some(workspace_index);
-
- let remove_btn = IconButton::new(
- format!("remove-workspace-{}", workspace_index),
- IconName::Close,
- )
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text("Remove Workspace"))
- .on_click({
- let multi_workspace = multi_workspace;
- move |_, window, cx| {
- multi_workspace.update(cx, |mw, cx| {
- mw.remove_workspace(workspace_index, window, cx);
- });
- }
- });
-
- let has_notification = self.notified_workspaces.contains(&workspace_index);
- let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone());
- let status = thread_info
- .as_ref()
- .map_or(AgentThreadStatus::default(), |info| info.status);
- let running = matches!(
- status,
- AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
- );
-
- Some(
- ThreadItem::new(
- ("workspace-item", thread_entry.index),
- thread_subtitle.unwrap_or("New Thread".into()),
- )
- .icon(
- thread_info
- .as_ref()
- .map_or(IconName::ZedAgent, |info| info.icon),
- )
- .running(running)
- .generation_done(has_notification)
- .status(status)
- .selected(selected)
- .worktree(worktree_label.clone())
- .worktree_highlight_positions(positions.clone())
- .when(workspace_count > 1, |item| item.action_slot(remove_btn))
- .hovered(is_hovered)
- .on_hover(cx.listener(move |picker, is_hovered, _window, cx| {
- let mut changed = false;
- if *is_hovered {
- if picker.delegate.hovered_thread_item != Some(workspace_index) {
- picker.delegate.hovered_thread_item = Some(workspace_index);
- changed = true;
- }
- } else if picker.delegate.hovered_thread_item == Some(workspace_index) {
- picker.delegate.hovered_thread_item = None;
- changed = true;
- }
- if changed {
- cx.notify();
- }
- }))
- .when(!full_path.is_empty(), |this| {
- this.tooltip(move |_, cx| {
- Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx)
- })
- })
- .into_any_element(),
- )
- }
- SidebarEntry::RecentProject(project_entry) => {
- let name = project_entry.name.clone();
- let full_path = project_entry.full_path.clone();
- let item_id: SharedString =
- format!("recent-project-{:?}", project_entry.workspace_id).into();
-
- Some(
- ThreadItem::new(item_id, name.clone())
- .icon(IconName::Folder)
- .selected(selected)
- .highlight_positions(positions.clone())
- .tooltip(move |_, cx| {
- Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
- })
- .into_any_element(),
- )
- }
- }
- }
-
- fn render_editor(
- &self,
- editor: &Arc<dyn ErasedEditor>,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Div {
- h_flex()
- .h(Tab::container_height(cx))
- .w_full()
- .px_2()
- .gap_2()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- Icon::new(IconName::MagnifyingGlass)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(editor.render(window, cx))
- }
+#[derive(Clone, Debug)]
+#[allow(dead_code)]
+enum ListEntry {
+ ProjectHeader {
+ path_list: PathList,
+ label: SharedString,
+ },
+ Thread {
+ session_id: acp::SessionId,
+ title: SharedString,
+ icon: IconName,
+ status: AgentThreadStatus,
+ updated_at: DateTime<Utc>,
+ diff_stats: Option<(usize, usize)>,
+ workspace_index: Option<usize>,
+ },
+ ViewMore {
+ path_list: PathList,
+ remaining_count: usize,
+ },
}
pub struct Sidebar {
+ // Reference cycle with the Workspace?
multi_workspace: Entity<MultiWorkspace>,
width: Pixels,
- picker: Entity<Picker<WorkspacePickerDelegate>>,
+ focus_handle: FocusHandle,
+ list_state: ListState,
+ entries: Vec<ListEntry>,
+ selection: Option<usize>,
+ collapsed_groups: HashSet<PathList>,
+ expanded_groups: HashSet<PathList>,
+ notified_workspaces: HashSet<usize>,
_subscription: Subscription,
_project_subscriptions: Vec<Subscription>,
_agent_panel_subscriptions: Vec<Subscription>,
_thread_subscriptions: Vec<Subscription>,
- #[cfg(any(test, feature = "test-support"))]
- test_thread_infos: HashMap<usize, AgentThreadInfo>,
- #[cfg(any(test, feature = "test-support"))]
- test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
- _fetch_recent_projects: Task<()>,
+ _thread_store_subscription: Option<Subscription>,
}
impl EventEmitter<SidebarEvent> for Sidebar {}
@@ -716,14 +79,6 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
- let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
- let picker = cx.new(|cx| {
- Picker::list(delegate, window, cx)
- .max_height(None)
- .show_scrollbar(true)
- .modal(false)
- });
-
let subscription = cx.observe_in(
&multi_workspace,
window,
@@ -732,38 +87,21 @@ impl Sidebar {
},
);
- let fetch_recent_projects = {
- let picker = picker.downgrade();
- let fs = <dyn Fs>::global(cx);
- cx.spawn_in(window, async move |_this, cx| {
- let projects = get_recent_projects(None, None, fs).await;
-
- cx.update(|window, cx| {
- if let Some(picker) = picker.upgrade() {
- picker.update(cx, |picker, cx| {
- picker.delegate.set_recent_projects(projects, cx);
- let query = picker.query(cx);
- picker.update_matches(query, window, cx);
- });
- }
- })
- .log_err();
- })
- };
-
let mut this = Self {
multi_workspace,
width: DEFAULT_WIDTH,
- picker,
+ focus_handle: cx.focus_handle(),
+ list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
+ entries: Vec::new(),
+ selection: None,
+ collapsed_groups: HashSet::new(),
+ expanded_groups: HashSet::new(),
+ notified_workspaces: HashSet::new(),
_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(),
- #[cfg(any(test, feature = "test-support"))]
- test_recent_project_thread_titles: HashMap::new(),
- _fetch_recent_projects: fetch_recent_projects,
+ _thread_store_subscription: None,
};
this.update_entries(window, cx);
this
@@ -801,149 +139,470 @@ impl Sidebar {
.collect()
}
- fn build_workspace_thread_entries(
- &self,
- multi_workspace: &MultiWorkspace,
- cx: &App,
- ) -> (Vec<WorkspaceThreadEntry>, usize) {
- #[allow(unused_mut)]
- let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
- .workspaces()
+ fn subscribe_to_agent_panels(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+ workspaces
.iter()
- .enumerate()
- .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx))
- .collect();
+ .map(|workspace| {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ cx.subscribe_in(
+ &agent_panel,
+ window,
+ |this, _, _event: &AgentPanelEvent, window, cx| {
+ this.update_entries(window, cx);
+ },
+ )
+ } else {
+ cx.observe_in(workspace, window, |this, _, window, cx| {
+ this.update_entries(window, cx);
+ })
+ }
+ })
+ .collect()
+ }
+
+ fn subscribe_to_threads(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
- #[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());
+ workspaces
+ .iter()
+ .filter_map(|workspace| {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let thread = agent_panel.read(cx).active_agent_thread(cx)?;
+ Some(cx.observe_in(&thread, window, |this, _, window, cx| {
+ this.update_entries(window, cx);
+ }))
+ })
+ .collect()
+ }
+
+ fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if self._thread_store_subscription.is_some() {
+ return;
+ }
+ if let Some(thread_store) = ThreadStore::try_global(cx) {
+ self._thread_store_subscription =
+ Some(cx.observe_in(&thread_store, window, |this, _, window, cx| {
+ this.update_entries(window, cx);
+ }));
+ }
+ }
+
+ fn workspace_path_list_and_label(
+ workspace: &Entity<Workspace>,
+ cx: &App,
+ ) -> (PathList, SharedString) {
+ let workspace_ref = workspace.read(cx);
+ let mut paths = Vec::new();
+ let mut names = Vec::new();
+
+ for worktree in workspace_ref.worktrees(cx) {
+ let worktree_ref = worktree.read(cx);
+ if !worktree_ref.is_visible() {
+ continue;
+ }
+ let abs_path = worktree_ref.abs_path();
+ paths.push(abs_path.to_path_buf());
+ if let Some(name) = abs_path.file_name() {
+ names.push(name.to_string_lossy().to_string());
}
}
- (entries, multi_workspace.active_workspace_index())
- }
+ let label: SharedString = if names.is_empty() {
+ // TODO: Can we do something better in this case?
+ "Empty Workspace".into()
+ } else {
+ names.join(", ").into()
+ };
+
+ (PathList::new(&paths), label)
+ }
+
+ fn active_thread_info_for_workspace(
+ workspace: &Entity<Workspace>,
+ cx: &App,
+ ) -> Option<ActiveThreadInfo> {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let agent_panel_ref = agent_panel.read(cx);
+ let thread_view = agent_panel_ref.as_active_thread_view(cx)?;
+ let thread_view_ref = thread_view.read(cx);
+ let thread = thread_view_ref.thread.read(cx);
+
+ let icon = thread_view_ref.agent_icon;
+ let title = thread.title();
+ let session_id = thread.session_id().clone();
+
+ let status = if thread.is_waiting_for_confirmation() {
+ AgentThreadStatus::WaitingForConfirmation
+ } else if thread.had_error() {
+ AgentThreadStatus::Error
+ } else {
+ match thread.status() {
+ ThreadStatus::Generating => AgentThreadStatus::Running,
+ ThreadStatus::Idle => AgentThreadStatus::Completed,
+ }
+ };
+
+ Some(ActiveThreadInfo {
+ session_id,
+ title,
+ status,
+ icon,
+ })
+ }
+
+ fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let multi_workspace = self.multi_workspace.clone();
+ cx.defer_in(window, move |this, window, cx| {
+ if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) {
+ return;
+ }
+
+ 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);
+ this.subscribe_to_thread_store(window, cx);
+
+ let (workspaces, active_workspace_index) = {
+ let mw = multi_workspace.read(cx);
+ (mw.workspaces().to_vec(), mw.active_workspace_index())
+ };
+
+ let thread_store = ThreadStore::try_global(cx);
+
+ let had_notifications = !this.notified_workspaces.is_empty();
+
+ let old_statuses: HashMap<usize, AgentThreadStatus> = this
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ ListEntry::Thread {
+ workspace_index: Some(index),
+ status,
+ ..
+ } => Some((*index, *status)),
+ _ => None,
+ })
+ .collect();
+
+ this.entries.clear();
+
+ for (index, workspace) in workspaces.iter().enumerate() {
+ let (path_list, label) =
+ Self::workspace_path_list_and_label(workspace, cx);
+
+ this.entries.push(ListEntry::ProjectHeader {
+ path_list: path_list.clone(),
+ label,
+ });
+
+ if this.collapsed_groups.contains(&path_list) {
+ continue;
+ }
+
+ let mut threads: Vec<ListEntry> = Vec::new();
+
+ if let Some(ref thread_store) = thread_store {
+ for meta in thread_store.read(cx).threads_for_paths(&path_list) {
+ threads.push(ListEntry::Thread {
+ session_id: meta.id.clone(),
+ title: meta.title.clone(),
+ icon: IconName::ZedAgent,
+ status: AgentThreadStatus::default(),
+ updated_at: meta.updated_at,
+ diff_stats: None,
+ workspace_index: None,
+ });
+ }
+ }
+
+ let active_info = Self::active_thread_info_for_workspace(workspace, cx);
+
+ if let Some(info) = &active_info {
+ let existing = threads.iter_mut().find(|t| {
+ matches!(t, ListEntry::Thread { session_id, .. } if session_id == &info.session_id)
+ });
+
+ if let Some(existing) = existing {
+ if let ListEntry::Thread {
+ status,
+ icon,
+ workspace_index,
+ title,
+ ..
+ } = existing
+ {
+ *status = info.status;
+ *icon = info.icon;
+ *workspace_index = Some(index);
+ *title = info.title.clone();
+ }
+ } else {
+ threads.push(ListEntry::Thread {
+ session_id: info.session_id.clone(),
+ title: info.title.clone(),
+ icon: info.icon,
+ status: info.status,
+ updated_at: Utc::now(),
+ diff_stats: None,
+ workspace_index: Some(index),
+ });
+ }
+ }
+
+ // Detect Running β Completed transitions on background workspaces.
+ for thread in &threads {
+ if let ListEntry::Thread {
+ workspace_index: Some(workspace_idx),
+ status,
+ ..
+ } = thread
+ {
+ if *status == AgentThreadStatus::Completed
+ && *workspace_idx != active_workspace_index
+ && old_statuses.get(workspace_idx) == Some(&AgentThreadStatus::Running)
+ {
+ this.notified_workspaces.insert(*workspace_idx);
+ }
+ }
+ }
+
+ threads.sort_by(|a, b| {
+ let a_time = match a {
+ ListEntry::Thread { updated_at, .. } => updated_at,
+ _ => unreachable!(),
+ };
+ let b_time = match b {
+ ListEntry::Thread { updated_at, .. } => updated_at,
+ _ => unreachable!(),
+ };
+ b_time.cmp(a_time)
+ });
+
+ let total = threads.len();
+ let show_view_more =
+ total > DEFAULT_THREADS_SHOWN && !this.expanded_groups.contains(&path_list);
+
+ let count = if show_view_more {
+ DEFAULT_THREADS_SHOWN
+ } else {
+ total
+ };
+
+ this.entries.extend(threads.into_iter().take(count));
+
+ if show_view_more {
+ this.entries.push(ListEntry::ViewMore {
+ path_list: path_list.clone(),
+ remaining_count: total - DEFAULT_THREADS_SHOWN,
+ });
+ }
+ }
+
+ this.notified_workspaces.remove(&active_workspace_index);
+
+ this.list_state.reset(this.entries.len());
+
+ if let Some(selection) = this.selection {
+ if selection >= this.entries.len() {
+ this.selection = this.entries.len().checked_sub(1);
+ }
+ }
+
+ let has_notifications = !this.notified_workspaces.is_empty();
+ if had_notifications != has_notifications {
+ multi_workspace.update(cx, |_, cx| cx.notify());
+ }
- #[cfg(any(test, feature = "test-support"))]
- pub fn set_test_recent_projects(
- &self,
- projects: Vec<RecentProjectEntry>,
- cx: &mut Context<Self>,
- ) {
- self.picker.update(cx, |picker, _cx| {
- picker.delegate.recent_projects = projects;
+ cx.notify();
});
}
- #[cfg(any(test, feature = "test-support"))]
- pub fn set_test_thread_info(
+ fn render_list_entry(
&mut self,
- index: usize,
- title: SharedString,
- status: AgentThreadStatus,
- ) {
- self.test_thread_infos.insert(
- index,
- AgentThreadInfo {
+ ix: usize,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let Some(entry) = self.entries.get(ix) else {
+ return div().into_any_element();
+ };
+ let is_selected = self.selection == Some(ix);
+
+ match entry {
+ ListEntry::ProjectHeader { path_list, label } => {
+ self.render_project_header(path_list, label, cx)
+ }
+ ListEntry::Thread {
title,
+ icon,
status,
- icon: IconName::ZedAgent,
- },
- );
+ workspace_index,
+ ..
+ } => self.render_thread(ix, title, *icon, *status, *workspace_index, is_selected, cx),
+ ListEntry::ViewMore {
+ path_list,
+ remaining_count,
+ } => self.render_view_more(ix, path_list, *remaining_count, cx),
+ }
}
- #[cfg(any(test, feature = "test-support"))]
- pub fn set_test_recent_project_thread_title(
- &mut self,
- full_path: SharedString,
- title: SharedString,
+ fn render_project_header(
+ &self,
+ path_list: &PathList,
+ label: &SharedString,
cx: &mut Context<Self>,
- ) {
- 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);
- });
+ ) -> AnyElement {
+ let is_collapsed = self.collapsed_groups.contains(path_list);
+ let disclosure_icon = if is_collapsed {
+ IconName::ChevronRight
+ } else {
+ IconName::ChevronDown
+ };
+ let path_list = path_list.clone();
+
+ h_flex()
+ .id(SharedString::from(format!("project-header-{}", label)))
+ .w_full()
+ .px_2()
+ .py_1()
+ .gap_1()
+ .child(
+ Icon::new(disclosure_icon)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(label.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .cursor_pointer()
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.toggle_collapse(&path_list, window, cx);
+ }))
+ .into_any_element()
}
- fn subscribe_to_agent_panels(
+ fn toggle_collapse(
&mut self,
+ path_list: &PathList,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> Vec<Subscription> {
- let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
-
- workspaces
- .iter()
- .map(|workspace| {
- if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
- cx.subscribe_in(
- &agent_panel,
- window,
- |this, _, _event: &AgentPanelEvent, window, cx| {
- this.update_entries(window, cx);
- },
- )
- } else {
- // Panel hasn't loaded yet β observe the workspace so we
- // re-subscribe once the panel appears on its dock.
- cx.observe_in(workspace, window, |this, _, window, cx| {
- this.update_entries(window, cx);
- })
- }
- })
- .collect()
+ ) {
+ if self.collapsed_groups.contains(path_list) {
+ self.collapsed_groups.remove(path_list);
+ } else {
+ self.collapsed_groups.insert(path_list.clone());
+ }
+ self.update_entries(window, cx);
}
- fn subscribe_to_threads(
- &mut self,
- window: &mut Window,
+ fn render_thread(
+ &self,
+ ix: usize,
+ title: &SharedString,
+ icon: IconName,
+ status: AgentThreadStatus,
+ workspace_index: Option<usize>,
+ is_selected: bool,
cx: &mut Context<Self>,
- ) -> Vec<Subscription> {
- let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+ ) -> AnyElement {
+ let running = matches!(
+ status,
+ AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
+ );
- workspaces
- .iter()
- .filter_map(|workspace| {
- let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
- let thread = agent_panel.read(cx).active_agent_thread(cx)?;
- Some(cx.observe_in(&thread, window, |this, _, window, cx| {
- this.update_entries(window, cx);
- }))
- })
- .collect()
- }
+ let has_notification = workspace_index
+ .map(|idx| self.notified_workspaces.contains(&idx))
+ .unwrap_or(false);
+
+ let is_active = workspace_index.is_some();
- /// Reconciles the sidebar's displayed entries with the current state of all
- /// workspaces and their agent threads.
- fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let multi_workspace = self.multi_workspace.clone();
- cx.defer_in(window, move |this, window, cx| {
- if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) {
- return;
- }
- 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| {
- this.build_workspace_thread_entries(multi_workspace, cx)
- });
+ h_flex()
+ .id(SharedString::from(format!("thread-entry-{}", ix)))
+ .w_full()
+ .px_2()
+ .py_1()
+ .gap_2()
+ .when(is_selected, |this| {
+ this.bg(cx.theme().colors().ghost_element_selected)
+ })
+ .rounded_md()
+ .cursor_pointer()
+ .child(Icon::new(icon).size(IconSize::Small).color(if running {
+ Color::Accent
+ } else {
+ Color::Muted
+ }))
+ .child(
+ div().flex_1().overflow_hidden().child(
+ Label::new(title.clone())
+ .size(LabelSize::Small)
+ .single_line()
+ .color(if is_active {
+ Color::Default
+ } else {
+ Color::Muted
+ }),
+ ),
+ )
+ .when(running, |this| {
+ this.child(
+ Label::new("Running")
+ .size(LabelSize::XSmall)
+ .color(Color::Accent),
+ )
+ })
+ .when(has_notification, |this| {
+ this.child(div().size_2().rounded_full().bg(cx.theme().status().info))
+ })
+ .on_click(cx.listener(move |_this, _, window, cx| {
+ if let Some(target_index) = workspace_index {
+ multi_workspace.update(cx, |multi_workspace, cx| {
+ multi_workspace.activate_index(target_index, window, cx);
+ });
+ }
+ }))
+ .into_any_element()
+ }
- 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());
- }
- });
+ fn render_view_more(
+ &self,
+ ix: usize,
+ path_list: &PathList,
+ remaining_count: usize,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let path_list = path_list.clone();
+
+ h_flex()
+ .id(SharedString::from(format!("view-more-{}", ix)))
+ .w_full()
+ .px_2()
+ .py_1()
+ .cursor_pointer()
+ .child(
+ Label::new(format!("+ View More ({})", remaining_count))
+ .size(LabelSize::Small)
+ .color(Color::Accent),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.expanded_groups.insert(path_list.clone());
+ this.update_entries(window, cx);
+ }))
+ .into_any_element()
}
}
@@ -50,7 +50,6 @@ use {
agent_servers::{AgentServer, AgentServerDelegate},
anyhow::{Context as _, Result},
assets::Assets,
- chrono::{Duration as ChronoDuration, Utc},
editor::display_map::DisplayRow,
feature_flags::FeatureFlagAppExt as _,
git_ui::project_diff::ProjectDiff,
@@ -60,7 +59,6 @@ use {
},
image::RgbaImage,
project_panel::ProjectPanel,
- recent_projects::RecentProjectEntry,
settings::{NotifyWhenAgentWaiting, Settings as _},
settings_ui::SettingsWindow,
std::{
@@ -71,7 +69,7 @@ use {
time::Duration,
},
util::ResultExt as _,
- workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId},
+ workspace::{AppState, MultiWorkspace, Workspace},
zed_actions::OpenSettingsAt,
};
@@ -2530,16 +2528,6 @@ fn run_multi_workspace_sidebar_visual_tests(
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()]);
@@ -2679,83 +2667,76 @@ fn run_multi_workspace_sidebar_visual_tests(
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 now = Utc::now();
- let today_timestamp = now;
- let yesterday_timestamp = now - ChronoDuration::days(1);
- let past_week_timestamp = now - ChronoDuration::days(10);
- let all_timestamp = now - ChronoDuration::days(60);
-
- 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(),
- timestamp: today_timestamp,
- },
- RecentProjectEntry {
- name: "font-kit".into(),
- full_path: recent2_dir.to_string_lossy().to_string().into(),
- paths: vec![recent2_dir.clone()],
- workspace_id: WorkspaceId::default(),
- timestamp: yesterday_timestamp,
- },
- RecentProjectEntry {
- name: "ideas".into(),
- full_path: recent3_dir.to_string_lossy().to_string().into(),
- paths: vec![recent3_dir.clone()],
- workspace_id: WorkspaceId::default(),
- timestamp: past_week_timestamp,
- },
- RecentProjectEntry {
- name: "tmp".into(),
- full_path: recent4_dir.to_string_lossy().to_string().into(),
- paths: vec![recent4_dir.clone()],
- workspace_id: WorkspaceId::default(),
- timestamp: all_timestamp,
- },
- ];
- sidebar.set_test_recent_projects(recent_projects, cx);
- });
- });
-
- // Set thread info directly on the sidebar for visual testing
- cx.update(|cx| {
- sidebar.update(cx, |sidebar, _cx| {
- sidebar.set_test_thread_info(
- 0,
- "Refine thread view scrolling behavior".into(),
- ui::AgentThreadStatus::Completed,
- );
- sidebar.set_test_thread_info(
- 1,
- "Add line numbers option to FileEditBlock".into(),
- ui::AgentThreadStatus::Running,
- );
- });
- });
+ // Save test threads to the ThreadStore for each workspace
+ let save_tasks = multi_workspace_window
+ .update(cx, |multi_workspace, _window, cx| {
+ let thread_store = agent::ThreadStore::global(cx);
+ let workspaces = multi_workspace.workspaces().to_vec();
+ let mut tasks = Vec::new();
+
+ for (index, workspace) in workspaces.iter().enumerate() {
+ let workspace_ref = workspace.read(cx);
+ let mut paths = Vec::new();
+ for worktree in workspace_ref.worktrees(cx) {
+ let worktree_ref = worktree.read(cx);
+ if worktree_ref.is_visible() {
+ paths.push(worktree_ref.abs_path().to_path_buf());
+ }
+ }
+ let path_list = util::path_list::PathList::new(&paths);
+
+ let (session_id, title, updated_at) = match index {
+ 0 => (
+ "visual-test-thread-0",
+ "Refine thread view scrolling behavior",
+ chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 10, 30, 0)
+ .unwrap(),
+ ),
+ 1 => (
+ "visual-test-thread-1",
+ "Add line numbers option to FileEditBlock",
+ chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 6, 15, 11, 0, 0)
+ .unwrap(),
+ ),
+ _ => continue,
+ };
+
+ let task = thread_store.update(cx, |store, cx| {
+ store.save_thread(
+ acp::SessionId::new(Arc::from(session_id)),
+ agent::DbThread {
+ title: title.to_string().into(),
+ messages: Vec::new(),
+ updated_at,
+ detailed_summary: None,
+ initial_project_snapshot: None,
+ cumulative_token_usage: Default::default(),
+ request_token_usage: Default::default(),
+ model: None,
+ profile: None,
+ imported: false,
+ subagent_context: None,
+ speed: None,
+ thinking_enabled: false,
+ thinking_effort: None,
+ },
+ path_list,
+ cx,
+ )
+ });
+ tasks.push(task);
+ }
+ tasks
+ })
+ .context("Failed to create test threads")?;
- // 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.background_executor.allow_parking();
+ for task in save_tasks {
+ cx.foreground_executor
+ .block_test(task)
+ .context("Failed to save test thread")?;
+ }
+ cx.background_executor.forbid_parking();
cx.run_until_parked();