diff --git a/Cargo.lock b/Cargo.lock index d37563dc8595c72f71901dd84cdd4fca5a34ee84..9ea059b0fdda47ba0650281be9bd1b998df95efe 100644 --- a/Cargo.lock +++ b/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", ] diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 5cdce12125da8f7d26677388169e899f94b7e7f1..c2866470081bb682306a3da1f37856c174751314 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -22,6 +22,10 @@ impl ThreadStore { cx.global::().0.clone() } + pub fn try_global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } + pub fn new(cx: &mut Context) -> Self { let this = Self { threads: Vec::new(), diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6165a41c68894df9ad60110663562df713a24470..8acf41f4175bbc692fd37cf5e97c0f0bdefc80a9 100644 --- a/crates/sidebar/Cargo.toml +++ b/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"] } \ No newline at end of file diff --git a/crates/sidebar/plan.md b/crates/sidebar/plan.md new file mode 100644 index 0000000000000000000000000000000000000000..902f6dcbd10373f1b5c7699b176bff50c5295916 --- /dev/null +++ b/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` 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, + diff_stats: Option<(usize, usize)>, + /// If this thread is actively running in a workspace, which one. + workspace_index: Option, + }, + /// "+ 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` — which project groups are collapsed. +- `expanded_groups: HashSet` — which groups have "View More" expanded (default is collapsed to N items). +- `selection: Option` — index into `entries`. +- `entries: Vec` — the flat list, rebuilt on every change. + +#### 1.3 Replace Picker with ListState-based rendering + +Drop the `Picker` entirely. Instead, follow the collab_panel pattern: + +- `Sidebar` owns a `ListState`, a `Vec`, an optional `selection: Option`, 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, + cx: &mut VisualTestContext, +) -> Vec { + 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` 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) | diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 24974512cda12276b5fcdc51ebd71d091782dff6..f148cda954c35299d8ceae9eae6f332f27994408 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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, -} - -impl WorkspaceThreadEntry { - fn new(index: usize, workspace: &Entity, 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 = 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::>() - .join("\n") - .into(); - - let thread_info = Self::thread_info(workspace, cx); - - Self { - index, - worktree_label, - full_path, - thread_info, - } - } - - fn thread_info(workspace: &Entity, cx: &App) -> Option { - let agent_panel = workspace.read(cx).panel::(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, -} - -struct WorkspacePickerDelegate { - multi_workspace: Entity, - entries: Vec, - 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, - recent_project_thread_titles: HashMap, - matches: Vec, - selected_index: usize, - query: String, - hovered_thread_item: Option, - notified_workspaces: HashSet, -} - -impl WorkspacePickerDelegate { - fn new(multi_workspace: Entity) -> 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, - 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 = 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, cx: &App) { - self.recent_project_thread_titles.clear(); - - self.recent_projects = recent_projects; - - let workspace_threads: Vec = 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>> { - 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, 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 = 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, window: &mut Window, cx: &mut App) { - let Some(handle) = window.window_handle().downcast::() 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>, - ) { - self.selected_index = ix; - } - - fn can_select( - &mut self, - ix: usize, - _window: &mut Window, - _cx: &mut Context>, - ) -> bool { - match self.matches.get(ix) { - Some(SidebarMatch { - entry: SidebarEntry::Separator(_), - .. - }) => false, - _ => true, - } - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search…".into() - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - 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>, - ) -> 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 = 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>) { - 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>) {} - - fn render_match( - &self, - index: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - 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, - window: &mut Window, - cx: &mut Context>, - ) -> 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, + diff_stats: Option<(usize, usize)>, + workspace_index: Option, + }, + ViewMore { + path_list: PathList, + remaining_count: usize, + }, } pub struct Sidebar { + // Reference cycle with the Workspace? multi_workspace: Entity, width: Pixels, - picker: Entity>, + focus_handle: FocusHandle, + list_state: ListState, + entries: Vec, + selection: Option, + collapsed_groups: HashSet, + expanded_groups: HashSet, + notified_workspaces: HashSet, _subscription: Subscription, _project_subscriptions: Vec, _agent_panel_subscriptions: Vec, _thread_subscriptions: Vec, - #[cfg(any(test, feature = "test-support"))] - test_thread_infos: HashMap, - #[cfg(any(test, feature = "test-support"))] - test_recent_project_thread_titles: HashMap, - _fetch_recent_projects: Task<()>, + _thread_store_subscription: Option, } impl EventEmitter for Sidebar {} @@ -716,14 +79,6 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) -> 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 = ::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, usize) { - #[allow(unused_mut)] - let mut entries: Vec = multi_workspace - .workspaces() + fn subscribe_to_agent_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces .iter() - .enumerate() - .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx)) - .collect(); + .map(|workspace| { + if let Some(agent_panel) = workspace.read(cx).panel::(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, + ) -> Vec { + 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::(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) { + 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, + 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, + cx: &App, + ) -> Option { + let agent_panel = workspace.read(cx).panel::(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) { + 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 = 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 = 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, - cx: &mut Context, - ) { - 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, + ) -> 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.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, - ) -> Vec { - let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); - - workspaces - .iter() - .map(|workspace| { - if let Some(agent_panel) = workspace.read(cx).panel::(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, + is_selected: bool, cx: &mut Context, - ) -> Vec { - 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::(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) { 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, + ) -> 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() } } @@ -957,14 +616,14 @@ impl WorkspaceSidebar for Sidebar { cx.notify(); } - fn has_notifications(&self, cx: &App) -> bool { - !self.picker.read(cx).delegate.notified_workspaces.is_empty() + fn has_notifications(&self, _cx: &App) -> bool { + !self.notified_workspaces.is_empty() } } impl Focusable for Sidebar { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.picker.read(cx).focus_handle(cx) + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() } } @@ -972,7 +631,7 @@ impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let titlebar_height = ui::utils::platform_title_bar_height(window); let ui_font = theme::setup_ui_font(window, cx); - let is_focused = self.focus_handle(cx).is_focused(window); + let is_focused = self.focus_handle.is_focused(window); let focus_tooltip_label = if is_focused { "Focus Workspace" @@ -983,6 +642,7 @@ impl Render for Sidebar { v_flex() .id("workspace-sidebar") .key_context("WorkspaceSidebar") + .track_focus(&self.focus_handle) .font(ui_font) .h_full() .w(self.width) @@ -1006,7 +666,8 @@ impl Render for Sidebar { .border_b_1() .border_color(cx.theme().colors().border) .child({ - let focus_handle = cx.focus_handle(); + let focus_handle_toggle = self.focus_handle.clone(); + let focus_handle_focus = self.focus_handle.clone(); IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) .icon_size(IconSize::Small) .tooltip(Tooltip::element(move |_, cx| { @@ -1019,7 +680,7 @@ impl Render for Sidebar { .child(Label::new("Close Sidebar")) .child(KeyBinding::for_action_in( &ToggleWorkspaceSidebar, - &focus_handle, + &focus_handle_toggle, cx, )), ) @@ -1033,7 +694,7 @@ impl Render for Sidebar { .child(Label::new(focus_tooltip_label)) .child(KeyBinding::for_action_in( &FocusWorkspaceSidebar, - &focus_handle, + &focus_handle_focus, cx, )), ) @@ -1056,17 +717,28 @@ impl Render for Sidebar { })), ), ) - .child(self.picker.clone()) + .child( + div().flex_1().overflow_hidden().child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .size_full(), + ), + ) } } #[cfg(test)] mod tests { use super::*; + use agent::ThreadStore; use feature_flags::FeatureFlagAppExt as _; use fs::FakeFs; use gpui::TestAppContext; use settings::SettingsStore; + use std::sync::Arc; + use util::path_list::PathList; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1075,199 +747,434 @@ mod tests { theme::init(theme::LoadThemes::JustBase, cx); editor::init(cx); cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); }); } - fn set_thread_info_and_refresh( - sidebar: &Entity, + fn make_test_thread(title: &str, updated_at: DateTime) -> agent::DbThread { + 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, + } + } + + async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, + ) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await + } + + fn setup_sidebar( multi_workspace: &Entity, - index: usize, - title: &str, - status: AgentThreadStatus, cx: &mut gpui::VisualTestContext, - ) { - sidebar.update_in(cx, |s, _window, _cx| { - s.set_test_thread_info(index, SharedString::from(title.to_string()), status); + ) -> Entity { + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); }); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); + sidebar } - fn has_notifications(sidebar: &Entity, cx: &mut gpui::VisualTestContext) -> bool { - sidebar.read_with(cx, |s, cx| s.has_notifications(cx)) + fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Vec { + 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)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + format!(" {}{}{}{}", title, active, status_str, selected) + } + ListEntry::ViewMore { + remaining_count, .. + } => { + format!(" + View More ({}){}", remaining_count, selected) + } + } + }) + .collect() + }) } #[gpui::test] - async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; - + async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]"] + ); + } + + #[gpui::test] + async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from("thread-1")), + make_test_thread( + "Fix crash in project panel", + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + save_task.await.unwrap(); + + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from("thread-2")), + make_test_thread( + "Add inline diff view", + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) }); + save_task.await.unwrap(); cx.run_until_parked(); - // Create a second workspace and switch to it so workspace 0 is background. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); + } + + #[gpui::test] + async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from("thread-a1")), + make_test_thread( + "Thread A1", + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) }); + save_task.await.unwrap(); cx.run_until_parked(); - assert!( - !has_notifications(&sidebar, cx), - "should have no notifications initially" - ); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] ); - assert!( - !has_notifications(&sidebar, cx), - "Running status alone should not create a notification" - ); + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1", "v [Empty Workspace]"] ); - assert!( - has_notifications(&sidebar, cx), - "Running → Completed transition should create a notification" + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] ); } #[gpui::test] - async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; - + async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); - }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + + for i in 0..12 { + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + make_test_thread( + &format!("Thread {}", i + 1), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + ), + path_list.clone(), + cx, + ) + }); + save_task.await.unwrap(); + } cx.run_until_parked(); - // Workspace 0 is the active workspace — thread completes while - // the user is already looking at it. - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, - ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, - ); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); - assert!( - !has_notifications(&sidebar, cx), - "should not notify for the workspace the user is already looking at" + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More (7)", + ] ); } #[gpui::test] - async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs, [], cx).await; - + async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - - let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { - let mw_handle = cx.entity(); - cx.new(|cx| Sidebar::new(mw_handle, window, cx)) - }); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); + + let save_task = thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new(Arc::from("test-thread")), + make_test_thread( + "Test Thread", + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + path_list.clone(), + cx, + ) }); + save_task.await.unwrap(); cx.run_until_parked(); - // Create a second workspace so we can switch away and back. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - // Switch to workspace 1 so workspace 0 becomes a background workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Test Thread"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); }); cx.run_until_parked(); - // Thread on workspace 0 transitions Running → Completed while - // the user is looking at workspace 1. - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Running, - cx, + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] ); - set_thread_info_and_refresh( - &sidebar, - &multi_workspace, - 0, - "Test Thread", - AgentThreadStatus::Completed, - cx, + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Test Thread"] ); + } - assert!( - has_notifications(&sidebar, cx), - "background workspace completion should create a notification" + #[gpui::test] + async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + }, + // Thread with default (Completed) status, not active + ListEntry::Thread { + session_id: acp::SessionId::new(Arc::from("t-1")), + title: "Completed thread".into(), + icon: IconName::ZedAgent, + status: AgentThreadStatus::Completed, + updated_at: Utc::now(), + diff_stats: None, + workspace_index: None, + }, + // Active thread with Running status + ListEntry::Thread { + session_id: acp::SessionId::new(Arc::from("t-2")), + title: "Running thread".into(), + icon: IconName::ZedAgent, + status: AgentThreadStatus::Running, + updated_at: Utc::now(), + diff_stats: None, + workspace_index: Some(0), + }, + // Active thread with Error status + ListEntry::Thread { + session_id: acp::SessionId::new(Arc::from("t-3")), + title: "Error thread".into(), + icon: IconName::ZedAgent, + status: AgentThreadStatus::Error, + updated_at: Utc::now(), + diff_stats: None, + workspace_index: Some(1), + }, + // Thread with WaitingForConfirmation status, not active + ListEntry::Thread { + session_id: acp::SessionId::new(Arc::from("t-4")), + title: "Waiting thread".into(), + icon: IconName::ZedAgent, + status: AgentThreadStatus::WaitingForConfirmation, + updated_at: Utc::now(), + diff_stats: None, + workspace_index: None, + }, + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + remaining_count: 42, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + }, + ]; + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " + View More (42)", + "> [collapsed-project]", + ] ); - // Switching back to workspace 0 should clear the notification. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(6); }); - cx.run_until_parked(); - assert!( - !has_notifications(&sidebar, cx), - "notification should be cleared when workspace becomes active" + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } } } diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 1f923769780de2ae7f1dc18d3334020960ff3bb6..7d605c7924a7d9c25a89634ca7339a457fb99ae4 100644 --- a/crates/util/src/path_list.rs +++ b/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]>, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 82b730ee8f1b50f6f46a7400be908a9442e115d1..73c96b8669218c3535b7e0a8a2db4f78302a55f8 100644 --- a/crates/zed/Cargo.toml +++ b/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", ] diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 0ae98d510aa34b05f7fa1766176f21ea353394d9..74fd64b20dfddce196a20ad8cf192dcd570e8b7d 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/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();