init new multi-agent approach

Mikayla Maki and Eric created

co-authored-by: Eric <eric@zed.dev>

Change summary

Cargo.lock                           |    7 
crates/agent/src/thread_store.rs     |    4 
crates/sidebar/Cargo.toml            |   12 
crates/sidebar/plan.md               |  443 ++++++++++
crates/sidebar/src/sidebar.rs        | 1315 +++++++++++------------------
crates/util/src/path_list.rs         |    2 
crates/zed/Cargo.toml                |    1 
crates/zed/src/visual_test_runner.rs |  159 +--
8 files changed, 1,013 insertions(+), 930 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -15731,20 +15731,19 @@ name = "sidebar"
 version = "0.1.0"
 dependencies = [
  "acp_thread",
+ "agent",
+ "agent-client-protocol",
  "agent_ui",
  "chrono",
  "editor",
  "feature_flags",
  "fs",
- "fuzzy",
  "gpui",
- "picker",
  "project",
- "recent_projects",
+ "serde_json",
  "settings",
  "theme",
  "ui",
- "ui_input",
  "util",
  "workspace",
 ]

crates/agent/src/thread_store.rs πŸ”—

@@ -22,6 +22,10 @@ impl ThreadStore {
         cx.global::<GlobalThreadStore>().0.clone()
     }
 
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalThreadStore>().map(|g| g.0.clone())
+    }
+
     pub fn new(cx: &mut Context<Self>) -> Self {
         let this = Self {
             threads: Vec::new(),

crates/sidebar/Cargo.toml πŸ”—

@@ -13,30 +13,28 @@ path = "src/sidebar.rs"
 
 [features]
 default = []
-test-support = []
 
 [dependencies]
 acp_thread.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
 agent_ui.workspace = true
 chrono.workspace = true
 fs.workspace = true
-fuzzy.workspace = true
 gpui.workspace = true
-picker.workspace = true
 project.workspace = true
-recent_projects.workspace = true
 theme.workspace = true
 ui.workspace = true
-ui_input.workspace = true
 util.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]
+agent = { workspace = true, features = ["test-support"] }
 editor.workspace = true
+serde_json.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"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/sidebar/plan.md πŸ”—

@@ -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) |

crates/sidebar/src/sidebar.rs πŸ”—

@@ -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()
     }
 }
 

crates/util/src/path_list.rs πŸ”—

@@ -13,7 +13,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
 /// other path lists without regard to the order of the paths.
 ///
 /// The paths can be retrieved in the original order using `ordered_paths()`.
-#[derive(Default, PartialEq, Eq, Debug, Clone)]
+#[derive(Default, PartialEq, Eq, Hash, Debug, Clone)]
 pub struct PathList {
     /// The paths, in lexicographic order.
     paths: Arc<[PathBuf]>,

crates/zed/Cargo.toml πŸ”—

@@ -50,7 +50,6 @@ visual-tests = [
     "language_model/test-support",
     "fs/test-support",
     "recent_projects/test-support",
-    "sidebar/test-support",
     "title_bar/test-support",
 ]
 

crates/zed/src/visual_test_runner.rs πŸ”—

@@ -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();