Key changes: - Phase A: Mechanical extract-class refactor (low risk, ship first) - Phase B: Enable second workspace, refactor stores as needed - Defer store complexity until we have test infrastructure - Focus on testability as the driving motivation
Unified · Split
| @@ -1,289 +0,0 @@ | |||
| 1 | # Cross-Workspace Thread Handling Plan | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This document outlines the design for handling agent threads across multiple workspaces, turning "zombie threads" from a bug into a feature: **background threads**. | ||
| 6 | |||
| 7 | ## Problem Statement | ||
| 8 | |||
| 9 | Opening the same thread in multiple workspaces currently has significant issues: | ||
| 10 | |||
| 11 | 1. **Flow disruption**: Opening a thread in two windows causes them to get out of sync | ||
| 12 | 2. **Zombie threads**: Closing a window does NOT stop the thread from running - it continues processing in the background | ||
| 13 | 4. **Split brain**: Each workspace creates its own `AcpThread` entity with no coordination | ||
| 14 | |||
| 15 | ### Current Architecture | ||
| 16 | |||
| 17 | | Aspect | Current Behavior | | ||
| 18 | |--------|------------------| | ||
| 19 | | Thread data | Persisted to SQLite database, shared across workspaces | | ||
| 20 | | Running state | Per-workspace (no coordination) | | ||
| 21 | | Thread views | Each workspace creates its own `AcpThreadView` and `AcpThread` entity | | ||
| 22 | | Coordination | None between workspaces for the same thread | | ||
| 23 | |||
| 24 | ## Research Findings | ||
| 25 | |||
| 26 | ### AcpThread is Project-Scoped | ||
| 27 | |||
| 28 | `AcpThread` takes an `Entity<Project>` and uses it for: | ||
| 29 | - **Language registry** - syntax highlighting in markdown/diffs | ||
| 30 | - **Path style** - Windows vs Unix path formatting | ||
| 31 | - **Git store** - checkpoints and restore functionality | ||
| 32 | - **Buffer operations** - `read_text_file`, `write_text_file` via `project.open_buffer()` | ||
| 33 | - **Terminal environment** - directory environment for spawned terminals | ||
| 34 | - **Path resolution** - `project_path_for_absolute_path()` | ||
| 35 | |||
| 36 | **Implication**: Threads cannot be meaningfully shared across projects. A thread from Project A cannot operate on Project B's files. | ||
| 37 | |||
| 38 | ### No Cancellation on Drop | ||
| 39 | |||
| 40 | - `AcpThread` has no `Drop` implementation | ||
| 41 | - When `AcpThreadView` is dropped, it only cleans up notification windows | ||
| 42 | - The `send_task` (background LLM work) continues running when entities are dropped | ||
| 43 | - `cancel()` must be called explicitly to stop a running thread | ||
| 44 | |||
| 45 | ### Existing Patterns in Zed | ||
| 46 | |||
| 47 | - **`BufferStore`**: Per-project store that provides `Entity<Buffer>` instances. Multiple `Editor`s can display the same buffer. Uses `WeakEntity` and deduplicates concurrent loads. | ||
| 48 | - **`ActiveCall`**: Global singleton for call/room state across workspaces. Uses `impl Global` pattern. | ||
| 49 | - **`WorkspaceStore`**: Tracks all open workspaces via `HashSet<WindowHandle<Workspace>>`. | ||
| 50 | |||
| 51 | ## Proposed Solution: AgentThreadStore | ||
| 52 | |||
| 53 | Create a new **global** `AgentThreadStore` in a new `agent_2` crate that: | ||
| 54 | 1. **Owns running threads** - holds strong references to keep them alive | ||
| 55 | 2. **Tracks view state** - which window (if any) is viewing each thread | ||
| 56 | 3. **Enables background threads** - threads continue running when windows close | ||
| 57 | |||
| 58 | ### Design | ||
| 59 | |||
| 60 | ```rust | ||
| 61 | // crates/agent_2/src/agent_thread_store.rs | ||
| 62 | |||
| 63 | pub struct AgentThreadStore { | ||
| 64 | /// Thread metadata from database (the "history") | ||
| 65 | threads: Vec<DbThreadMetadata>, | ||
| 66 | |||
| 67 | /// Running threads - store holds strong reference to keep them alive | ||
| 68 | /// These continue running even if no window is viewing them | ||
| 69 | running_threads: HashMap<acp::SessionId, Entity<AcpThread>>, | ||
| 70 | |||
| 71 | /// Which window (if any) is currently viewing each running thread | ||
| 72 | /// None = running in background, Some(window) = open in that window | ||
| 73 | thread_views: HashMap<acp::SessionId, WindowHandle<Workspace>>, | ||
| 74 | |||
| 75 | /// Recently opened for quick-access UI | ||
| 76 | recently_opened: VecDeque<acp::SessionId>, | ||
| 77 | } | ||
| 78 | |||
| 79 | /// State of a thread for UI display | ||
| 80 | pub enum ThreadState { | ||
| 81 | /// Not running (historical entry only) | ||
| 82 | Idle, | ||
| 83 | /// Running and visible in a window | ||
| 84 | Running { window: WindowHandle<Workspace> }, | ||
| 85 | /// Running in background (no window viewing it) | ||
| 86 | Background, | ||
| 87 | } | ||
| 88 | |||
| 89 | pub enum AgentThreadStoreEvent { | ||
| 90 | /// History list changed (thread added/removed/updated) | ||
| 91 | HistoryChanged, | ||
| 92 | /// A thread started running | ||
| 93 | ThreadStarted(acp::SessionId), | ||
| 94 | /// A thread stopped running | ||
| 95 | ThreadStopped(acp::SessionId), | ||
| 96 | /// A thread's view state changed (attached/detached from window) | ||
| 97 | ThreadViewChanged(acp::SessionId), | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Result of trying to open a thread | ||
| 101 | pub enum OpenResult { | ||
| 102 | /// Thread opened/attached in this window | ||
| 103 | Opened(Entity<AcpThread>), | ||
| 104 | /// Thread is open in a different window - that window's handle | ||
| 105 | OpenElsewhere(WindowHandle<Workspace>), | ||
| 106 | } | ||
| 107 | |||
| 108 | impl AgentThreadStore { | ||
| 109 | // --- Global access --- | ||
| 110 | pub fn global(cx: &mut App) -> Entity<Self>; | ||
| 111 | |||
| 112 | // --- History (read from database) --- | ||
| 113 | pub fn history(&self) -> &[DbThreadMetadata]; | ||
| 114 | pub fn recently_opened(&self) -> impl Iterator<Item = &DbThreadMetadata>; | ||
| 115 | pub fn reload(&mut self, cx: &mut Context<Self>) -> Task<()>; | ||
| 116 | |||
| 117 | // --- Thread state --- | ||
| 118 | |||
| 119 | /// Get state of a thread for UI display | ||
| 120 | pub fn thread_state(&self, session_id: &acp::SessionId) -> ThreadState; | ||
| 121 | |||
| 122 | /// Open/attach to a thread in this window. | ||
| 123 | /// - If idle: caller should create thread, then call `register_running` | ||
| 124 | /// - If background: attaches to this window | ||
| 125 | /// - If open elsewhere: returns that window handle | ||
| 126 | pub fn open_thread( | ||
| 127 | &mut self, | ||
| 128 | session_id: &acp::SessionId, | ||
| 129 | window: WindowHandle<Workspace>, | ||
| 130 | cx: &mut Context<Self>, | ||
| 131 | ) -> OpenResult; | ||
| 132 | |||
| 133 | /// Register a newly created running thread | ||
| 134 | pub fn register_running( | ||
| 135 | &mut self, | ||
| 136 | session_id: acp::SessionId, | ||
| 137 | thread: Entity<AcpThread>, | ||
| 138 | window: WindowHandle<Workspace>, | ||
| 139 | cx: &mut Context<Self>, | ||
| 140 | ); | ||
| 141 | |||
| 142 | /// Detach from a thread (window closing or user explicitly detaching) | ||
| 143 | /// Thread keeps running in background | ||
| 144 | pub fn detach_thread( | ||
| 145 | &mut self, | ||
| 146 | session_id: &acp::SessionId, | ||
| 147 | cx: &mut Context<Self>, | ||
| 148 | ); | ||
| 149 | |||
| 150 | /// Stop a thread completely (user explicitly stopping) | ||
| 151 | pub fn stop_thread( | ||
| 152 | &mut self, | ||
| 153 | session_id: &acp::SessionId, | ||
| 154 | cx: &mut Context<Self>, | ||
| 155 | ); | ||
| 156 | |||
| 157 | // --- Recently opened management --- | ||
| 158 | pub fn mark_recently_opened( | ||
| 159 | &mut self, | ||
| 160 | session_id: acp::SessionId, | ||
| 161 | cx: &mut Context<Self>, | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | ``` | ||
| 165 | |||
| 166 | ### Usage Flow | ||
| 167 | |||
| 168 | **Opening a thread from history:** | ||
| 169 | ```rust | ||
| 170 | fn open_thread(&mut self, session_id: acp::SessionId, window: &mut Window, cx: &mut Context<Self>) { | ||
| 171 | let store = AgentThreadStore::global(cx); | ||
| 172 | let this_window = window.window_handle().downcast::<Workspace>().unwrap(); | ||
| 173 | |||
| 174 | let state = store.read(cx).thread_state(&session_id); | ||
| 175 | |||
| 176 | match state { | ||
| 177 | ThreadState::Running { window } if window == this_window => { | ||
| 178 | // Already open here, just focus it | ||
| 179 | self.focus_thread_view(cx); | ||
| 180 | } | ||
| 181 | ThreadState::Running { window } => { | ||
| 182 | // Open in another window, activate that | ||
| 183 | cx.activate_window(window); | ||
| 184 | } | ||
| 185 | ThreadState::Background => { | ||
| 186 | // Running in background, attach to this window | ||
| 187 | store.update(cx, |store, cx| { | ||
| 188 | store.open_thread(&session_id, this_window, cx); | ||
| 189 | }); | ||
| 190 | self.show_thread_view(session_id, cx); | ||
| 191 | } | ||
| 192 | ThreadState::Idle => { | ||
| 193 | // Not running, need to create/resume | ||
| 194 | let thread = self.create_thread(session_id.clone(), cx); | ||
| 195 | store.update(cx, |store, cx| { | ||
| 196 | store.register_running(session_id, thread, this_window, cx); | ||
| 197 | }); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } | ||
| 201 | ``` | ||
| 202 | |||
| 203 | **Window close handling:** | ||
| 204 | ```rust | ||
| 205 | fn on_window_close(&mut self, window: WindowHandle<Workspace>, cx: &mut App) { | ||
| 206 | let store = AgentThreadStore::global(cx); | ||
| 207 | store.update(cx, |store, cx| { | ||
| 208 | // Detach all threads viewed in this window - they become background threads | ||
| 209 | for (session_id, view_window) in store.thread_views.iter() { | ||
| 210 | if *view_window == window { | ||
| 211 | store.detach_thread(session_id, cx); | ||
| 212 | } | ||
| 213 | } | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | ``` | ||
| 217 | |||
| 218 | **History view rendering:** | ||
| 219 | ```rust | ||
| 220 | fn render_history_entry(&self, entry: &DbThreadMetadata, cx: &App) -> impl IntoElement { | ||
| 221 | let store = AgentThreadStore::global(cx); | ||
| 222 | let state = store.read(cx).thread_state(&entry.id); | ||
| 223 | |||
| 224 | let status_indicator = match state { | ||
| 225 | ThreadState::Running { window } if window == self.this_window => { | ||
| 226 | Icon::new(IconName::Play).color(Color::Success) // 🟢 Running here | ||
| 227 | } | ||
| 228 | ThreadState::Running { .. } => { | ||
| 229 | Icon::new(IconName::Play).color(Color::Info) // 🔵 Running elsewhere | ||
| 230 | } | ||
| 231 | ThreadState::Background => { | ||
| 232 | Icon::new(IconName::CloudUpload).color(Color::Warning) // 🟡 Background | ||
| 233 | } | ||
| 234 | ThreadState::Idle => { | ||
| 235 | Icon::new(IconName::Circle).color(Color::Muted) // ⚪ Idle | ||
| 236 | } | ||
| 237 | }; | ||
| 238 | |||
| 239 | div() | ||
| 240 | .child(status_indicator) | ||
| 241 | .child(Label::new(&entry.title)) | ||
| 242 | } | ||
| 243 | ``` | ||
| 244 | |||
| 245 | ### Key Design Decisions | ||
| 246 | |||
| 247 | 1. **Global store owns running threads**: Strong `Entity<AcpThread>` references keep threads alive | ||
| 248 | 2. **Background threads are a feature**: Closing a window detaches, doesn't kill | ||
| 249 | 3. **Single view per thread**: Only one window can view a thread at a time | ||
| 250 | 4. **Redirect, don't duplicate**: If a thread is open elsewhere, redirect to that window | ||
| 251 | |||
| 252 | ## Future Vision | ||
| 253 | |||
| 254 | This refactor sets the foundation for a deeper architectural change: **threads as the primary object**. | ||
| 255 | |||
| 256 | In the future, threads will become richer objects that the editor UI is downstream of: | ||
| 257 | - Each thread has its own worktrees | ||
| 258 | - Each thread has its own workspace state | ||
| 259 | - The editor becomes a view into a thread's state | ||
| 260 | - Multiple threads can be running simultaneously, each with their own context | ||
| 261 | |||
| 262 | This is similar to how modern AI coding tools treat "sessions" or "conversations" as first-class workspace containers. | ||
| 263 | |||
| 264 | The current refactor provides immediate value by: | ||
| 265 | 1. ✅ Fixing the split-brain problem | ||
| 266 | 2. ✅ Enabling background threads as a feature | ||
| 267 | 3. ✅ Establishing the `AgentThreadStore` as the single source of truth | ||
| 268 | 4. ✅ Setting up the ownership model for the future vision | ||
| 269 | |||
| 270 | ## Implementation Plan | ||
| 271 | |||
| 272 | 1. [ ] Create new `agent_2` crate | ||
| 273 | 2. [ ] Implement `AgentThreadStore` with global access pattern | ||
| 274 | 3. [ ] Implement thread state tracking (`running_threads`, `thread_views`) | ||
| 275 | 4. [ ] Add `open_thread` / `detach_thread` / `stop_thread` APIs | ||
| 276 | 5. [ ] Wire up `AgentsPanel` (v2) to use `AgentThreadStore` | ||
| 277 | 6. [ ] Add window close handling to detach threads | ||
| 278 | 7. [ ] Update history view to show thread state (running/background/idle) | ||
| 279 | 8. [ ] Add tests for cross-workspace scenarios | ||
| 280 | 9. [ ] Add UI affordances for background threads (attach, stop, etc.) | ||
| 281 | |||
| 282 | ## Related Files | ||
| 283 | |||
| 284 | - `crates/agent_ui_v2/src/agents_panel.rs` - Will consume AgentThreadStore | ||
| 285 | - `crates/agent_ui_v2/src/agent_thread_pane.rs` - Thread pane management | ||
| 286 | - `crates/acp_thread/src/acp_thread.rs` - Thread entity and lifecycle | ||
| 287 | - `crates/agent/src/history_store.rs` - Existing history store (for reference) | ||
| 288 | - `crates/agent/src/db.rs` - Thread database types (DbThreadMetadata, etc.) | ||
| 289 | - `crates/workspace/src/workspace.rs` - Workspace lifecycle, WorkspaceStore pattern | ||
| @@ -6,7 +6,7 @@ We are refactoring Zed to support multiple Workspaces that share worktrees. The · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | ### Current Architecture | 7 | ### Current Architecture |
| 8 | ``` | 8 | ``` |
| 9 | Workspace | 9 | Workspace (being renamed to MultiWorkspace) |
| 10 | └── Entity<Project> | 10 | └── Entity<Project> |
| 11 | └── Entity<WorktreeStore> // Project creates and owns this | 11 | └── Entity<WorktreeStore> // Project creates and owns this |
| 12 | └── Entity<Worktree>... | 12 | └── Entity<Worktree>... |
| @@ -16,10 +16,10 @@ Workspace · 3 unmodified lines | |||
| 16 | └── ... other stores | 16 | └── ... other stores |
| 17 | ``` | 17 | ``` |
| 18 | 18 | ||
| 19 | ### New Architecture | 19 | ### Target Architecture |
| 20 | ``` | 20 | ``` |
| 21 | MultiWorkspace (new top-level container) | 21 | MultiWorkspace (window-level container) |
| 22 | ├── Entity<WorktreeStore> // Shared, contains mix of local AND remote worktrees | 22 | ├── Entity<WorktreeStore> // Shared, eventually contains mix of local AND remote worktrees |
| 23 | ├── Entity<BufferStore> // Shared - same file = same buffer | 23 | ├── Entity<BufferStore> // Shared - same file = same buffer |
| 24 | ├── Entity<GitStore> // Shared - git state is per-repo | 24 | ├── Entity<GitStore> // Shared - git state is per-repo |
| 25 | ├── Entity<LspStore> // Shared - one LSP instance per worktree | 25 | ├── Entity<LspStore> // Shared - one LSP instance per worktree |
| @@ -33,6 +33,7 @@ MultiWorkspace (new top-level container) · 7 unmodified lines | |||
| 33 | │ ├── Entity<DapStore> // Per-workspace (debugging sessions) | 33 | │ ├── Entity<DapStore> // Per-workspace (debugging sessions) |
| 34 | │ ├── Entity<TaskStore> // Per-workspace (task execution context) | 34 | │ ├── Entity<TaskStore> // Per-workspace (task execution context) |
| 35 | │ └── (references to shared stores) | 35 | │ └── (references to shared stores) |
| 36 | │ └── Panes, Panels, Docks (UI) | ||
| 36 | │ | 37 | │ |
| 37 | └── Workspace B | 38 | └── Workspace B |
| 38 | └── Entity<Project> | 39 | └── Entity<Project> |
| @@ -40,40 +41,115 @@ MultiWorkspace (new top-level container) · 1 unmodified line | |||
| 40 | ├── client_state: ProjectClientState | 41 | ├── client_state: ProjectClientState |
| 41 | ├── Entity<DapStore> | 42 | ├── Entity<DapStore> |
| 42 | └── Entity<TaskStore> | 43 | └── Entity<TaskStore> |
| 44 | └── Panes, Panels, Docks (UI) | ||
| 43 | ``` | 45 | ``` |
| 44 | 46 | ||
| 47 | ## Implementation Strategy | ||
| 48 | |||
| 49 | The key insight is: **defer store complexity until we actually have two workspaces trying to share them**. This gives us concrete test cases and clearer requirements. | ||
| 50 | |||
| 51 | ### Phase A: Structural Rename (Ship First) | ||
| 52 | |||
| 53 | **Goal**: Extract `Workspace` from `MultiWorkspace` as a mechanical refactor with no behavior change. | ||
| 54 | |||
| 55 | **Current State**: | ||
| 56 | - `Workspace` has been renamed to `MultiWorkspace` ✅ | ||
| 57 | - Stub `single_workspace::Workspace` module created ✅ | ||
| 58 | |||
| 59 | **What Moves to `Workspace`** (the new inner struct): | ||
| 60 | - `Entity<Project>` - workspace owns its project | ||
| 61 | - `center: PaneGroup` - pane layout | ||
| 62 | - `panes: Vec<Entity<Pane>>` - all panes | ||
| 63 | - `active_pane: Entity<Pane>` | ||
| 64 | - `left_dock`, `right_dock`, `bottom_dock` - dock UI | ||
| 65 | - `status_bar` - per-workspace status | ||
| 66 | - `modal_layer`, `toast_layer` - per-workspace overlays | ||
| 67 | - `notifications` - per-workspace notifications | ||
| 68 | - Most UI rendering logic | ||
| 69 | |||
| 70 | **What Stays in `MultiWorkspace`**: | ||
| 71 | - `Entity<WorktreeStore>` - shared (passed down to Projects) | ||
| 72 | - `Entity<Workspace>` - just one for now | ||
| 73 | - `weak_self`, `app_state` - window-level concerns | ||
| 74 | - `workspace_actions` - action registration | ||
| 75 | - `database_id`, serialization concerns | ||
| 76 | - `WorkspaceStore` registration | ||
| 77 | - Window title management | ||
| 78 | |||
| 79 | **This is "extract class" refactoring** - moving fields/methods down without changing behavior. Risk is low because it's mechanical. | ||
| 80 | |||
| 81 | **Merge Point**: Ship this rename, get it tested in the wild. | ||
| 82 | |||
| 83 | ### Phase B: Enable Second Workspace (Real Refactor) | ||
| 84 | |||
| 85 | **Goal**: Actually support multiple Workspace instances, refactoring stores as needed. | ||
| 86 | |||
| 87 | **Why This Order?**: You can't write integration tests for shared state without two workspaces. Once you have: | ||
| 88 | |||
| 89 | ```rust | ||
| 90 | struct MultiWorkspace { | ||
| 91 | worktree_store: Entity<WorktreeStore>, | ||
| 92 | workspaces: Vec<Entity<Workspace>>, | ||
| 93 | } | ||
| 94 | ``` | ||
| 95 | |||
| 96 | Then you can write tests like: | ||
| 97 | |||
| 98 | ```rust | ||
| 99 | #[gpui::test] | ||
| 100 | async fn test_two_workspaces_share_buffer(cx: &mut TestAppContext) { | ||
| 101 | let multi = cx.new(|cx| MultiWorkspace::new(...)); | ||
| 102 | |||
| 103 | // Add a worktree to the shared store | ||
| 104 | let worktree_id = multi.update(cx, |m, cx| m.add_worktree("/path", cx)).await; | ||
| 105 | |||
| 106 | // Create two workspaces viewing the same worktree | ||
| 107 | let ws_a = multi.update(cx, |m, cx| m.add_workspace(cx)); | ||
| 108 | let ws_b = multi.update(cx, |m, cx| m.add_workspace(cx)); | ||
| 109 | |||
| 110 | // Open same file in both, verify it's the same buffer | ||
| 111 | // Edit in one, verify it appears in the other | ||
| 112 | } | ||
| 113 | ``` | ||
| 114 | |||
| 115 | **Store Refactors** (driven by test failures): | ||
| 116 | 1. `WorktreeStore` - Remove `WorktreeStoreState` enum, push local/remote distinction into individual worktrees | ||
| 117 | 2. `BufferStore` - Lift to MultiWorkspace so same file = same buffer | ||
| 118 | 3. `GitStore` - Lift to MultiWorkspace (git state is per-repo) | ||
| 119 | 4. `LspStore` - Lift to MultiWorkspace (one LSP per worktree) | ||
| 120 | 5. Keep `DapStore`, `TaskStore` per-workspace | ||
| 121 | |||
| 122 | **Project Changes**: | ||
| 123 | - Add `worktree_ids: HashSet<WorktreeId>` to filter which worktrees it "views" | ||
| 124 | - `Project::worktrees()` filters by this set | ||
| 125 | - Project receives shared store references from MultiWorkspace | ||
| 126 | |||
| 45 | ## Key Design Decisions | 127 | ## Key Design Decisions |
| 46 | 128 | ||
| 47 | ### 1. WorktreeStore Contains Mixed Local/Remote Worktrees | 129 | ### 1. WorktreeStore Will Contain Mixed Local/Remote Worktrees |
| 48 | 130 | ||
| 49 | The shared WorktreeStore can contain both local and remote worktrees simultaneously: | 131 | Eventually the shared WorktreeStore can contain both local and remote worktrees: |
| 50 | - `LocalWorktree` for `~/projects/foo` (local filesystem) | 132 | - `LocalWorktree` for `~/projects/foo` (local filesystem) |
| 51 | - `RemoteWorktree` for `server-a:/home/user/bar` (SSH connection) | 133 | - `RemoteWorktree` for `server-a:/home/user/bar` (SSH connection) |
| 52 | - `RemoteWorktree` for `server-b:/work/baz` (different SSH connection) | ||
| 53 | 134 | ||
| 54 | This works because **transport lives at the Worktree level**, not WorktreeStore level: | 135 | This works because **transport lives at the Worktree level**: |
| 55 | - `LocalWorktree` has `fs: Arc<dyn Fs>` | 136 | - `LocalWorktree` has `fs: Arc<dyn Fs>` |
| 56 | - `RemoteWorktree` has `client: AnyProtoClient` and `project_id: u64` | 137 | - `RemoteWorktree` has `client: AnyProtoClient` and `project_id: u64` |
| 57 | 138 | ||
| 58 | When BufferStore opens a file, it delegates to the Worktree via `worktree.load_file()`, so the Worktree handles its own transport. | 139 | **Required change** (Phase B): Remove `WorktreeStoreState` enum. Add explicit methods: |
| 59 | |||
| 60 | **Required change**: Remove `WorktreeStoreState` enum (currently Local vs Remote). Instead, provide explicit methods for adding local vs remote worktrees: | ||
| 61 | ```rust | 140 | ```rust |
| 62 | impl WorktreeStore { | 141 | impl WorktreeStore { |
| 63 | pub fn add_local_worktree(&mut self, path: &Path, fs: Arc<dyn Fs>, cx: ...) -> Task<...> | 142 | pub fn add_local_worktree(&mut self, path: &Path, fs: Arc<dyn Fs>, ...) -> Task<...> |
| 64 | pub fn add_remote_worktree(&mut self, client: AnyProtoClient, project_id: u64, ...) -> Task<...> | 143 | pub fn add_remote_worktree(&mut self, client: AnyProtoClient, project_id: u64, ...) -> Task<...> |
| 65 | } | 144 | } |
| 66 | ``` | 145 | ``` |
| 67 | 146 | ||
| 68 | ### 2. Project Becomes a "View" Over Shared Worktrees | 147 | ### 2. Project Becomes a "View" Over Shared Worktrees |
| 69 | 148 | ||
| 70 | Project tracks which worktrees it contains via `worktree_ids: HashSet<WorktreeId>`: | ||
| 71 | |||
| 72 | ```rust | 149 | ```rust |
| 73 | struct Project { | 150 | struct Project { |
| 74 | worktree_ids: HashSet<WorktreeId>, | 151 | worktree_ids: HashSet<WorktreeId>, |
| 75 | worktree_store: Entity<WorktreeStore>, // Reference to shared store | 152 | worktree_store: Entity<WorktreeStore>, // Reference to shared store |
| 76 | // ... | ||
| 77 | } | 153 | } |
| 78 | 154 | ||
| 79 | impl Project { | 155 | impl Project { |
| @@ -85,38 +161,28 @@ impl Project { · 5 unmodified lines | |||
| 85 | } | 161 | } |
| 86 | ``` | 162 | ``` |
| 87 | 163 | ||
| 88 | Worktrees are **oblivious to Projects** - they don't track which Projects reference them. This keeps the relationship clean and unidirectional. | 164 | Worktrees are **oblivious to Projects** - they don't track which Projects reference them. |
| 89 | 165 | ||
| 90 | ### 3. Shared Stores vs Per-Workspace Stores | 166 | ### 3. Shared Stores vs Per-Workspace Stores |
| 91 | 167 | ||
| 92 | **Shared at MultiWorkspace level** (same file = same entity everywhere): | 168 | **Eventually Shared** (same file = same entity everywhere): |
| 93 | - `BufferStore` - Edits in Workspace A are immediately visible in Workspace B | 169 | - `BufferStore` - Edits in Workspace A visible in Workspace B |
| 94 | - `GitStore` - Git state is per-repository | 170 | - `GitStore` - Git state is per-repository |
| 95 | - `LspStore` - One language server instance per worktree, shared across workspaces | 171 | - `LspStore` - One language server instance per worktree |
| 96 | - `ImageStore` - Same as BufferStore | 172 | - `ImageStore` - Same as BufferStore |
| 97 | - `BreakpointStore` - Breakpoints are on files | 173 | - `BreakpointStore` - Breakpoints are on files |
| 98 | 174 | ||
| 99 | **Per-Workspace** (workspace-specific context): | 175 | **Per-Workspace** (workspace-specific context): |
| 100 | - `DapStore` - Debugging sessions are focused activities | 176 | - `DapStore` - Debugging sessions are focused activities |
| 101 | - `TaskStore` - Tasks run in a specific workspace context | 177 | - `TaskStore` - Tasks run in a specific workspace context |
| 102 | - `ContextServerStore` - Context servers may be workspace-scoped | ||
| 103 | |||
| 104 | ### 4. Collaboration Model (Option C: Share at Project Level) | ||
| 105 | 178 | ||
| 106 | Collaboration happens at the **Project level**, not WorktreeStore level: | 179 | ### 4. Collaboration Constraint |
| 107 | - A Project can be shared (gets a `remote_id` in `ProjectClientState::Shared`) | ||
| 108 | - Collaborators join a specific Project and see its worktrees | ||
| 109 | - The same worktree can be in multiple Projects, but... | ||
| 110 | 180 | ||
| 111 | **Constraint**: A worktree can only be in ONE shared Project at a time. | 181 | A worktree can only be in ONE shared Project at a time. Validate when sharing: |
| 112 | |||
| 113 | |||
| 114 | When sharing, validate: | ||
| 115 | ```rust | 182 | ```rust |
| 116 | impl Project { | 183 | impl Project { |
| 117 | pub fn share(&mut self, cx: &mut Context<Self>) -> Task<Result<u64>> { | 184 | pub fn share(&mut self, cx: &mut Context<Self>) -> Task<Result<u64>> { |
| 118 | // Check if any of our worktrees are already in a shared project | 185 | // Check if any of our worktrees are already in a shared project |
| 119 | let multi_workspace = MultiWorkspace::global(cx); | ||
| 120 | for worktree_id in &self.worktree_ids { | 186 | for worktree_id in &self.worktree_ids { |
| 121 | if let Some(other_project) = multi_workspace.find_sharing_project_for_worktree(*worktree_id, cx) { | 187 | if let Some(other_project) = multi_workspace.find_sharing_project_for_worktree(*worktree_id, cx) { |
| 122 | if other_project != cx.entity() { | 188 | if other_project != cx.entity() { |
| @@ -132,100 +198,19 @@ impl Project { · 9 unmodified lines | |||
| 132 | } | 198 | } |
| 133 | ``` | 199 | ``` |
| 134 | 200 | ||
| 135 | ### 5. RemoteWorktree.project_id Clarification | 201 | ## Key Files |
| 136 | |||
| 137 | There are two different IDs to understand: | ||
| 138 | 1. **`RemoteWorktree.project_id`** - The upstream source ID (e.g., SSH server's project ID). This is for RPC with the server hosting the files. | ||
| 139 | 2. **`Project.client_state.remote_id`** - The collaboration ID when this Project is shared with others. | ||
| 140 | |||
| 141 | These are orthogonal. A RemoteWorktree connected via SSH has a fixed `project_id` for that connection. That worktree can be part of multiple local Projects with different collaboration states. | ||
| 142 | |||
| 143 | ### 6. Worktree Lifecycle | ||
| 144 | |||
| 145 | Worktrees are reference-counted in the shared WorktreeStore: | ||
| 146 | - When a Workspace closes, its Project removes its worktree references | ||
| 147 | - The worktree stays alive if another Project still references it | ||
| 148 | - When the last reference is removed, the worktree is dropped | ||
| 149 | |||
| 150 | ## Implementation Plan | ||
| 151 | |||
| 152 | ### Phase 1: Create MultiWorkspace Container | ||
| 153 | |||
| 154 | 1. Create new `MultiWorkspace` struct that holds: | ||
| 155 | - `Entity<WorktreeStore>` | ||
| 156 | - `Entity<BufferStore>` | ||
| 157 | - `Entity<GitStore>` | ||
| 158 | - `Entity<LspStore>` | ||
| 159 | - `Entity<ImageStore>` | ||
| 160 | - `Entity<BreakpointStore>` | ||
| 161 | - `Vec<Entity<Workspace>>` or similar | ||
| 162 | |||
| 163 | 2. Make MultiWorkspace global or per-window (TBD based on window model) | ||
| 164 | |||
| 165 | 3. Update Workspace creation to: | ||
| 166 | - Get/create MultiWorkspace | ||
| 167 | - Create Project with reference to shared WorktreeStore | ||
| 168 | - Pass shared stores to Project | ||
| 169 | |||
| 170 | ### Phase 2: Refactor WorktreeStore | ||
| 171 | |||
| 172 | 1. Remove `WorktreeStoreState` enum | ||
| 173 | 2. Add explicit `add_local_worktree` and `add_remote_worktree` methods | ||
| 174 | 3. Update `find_or_create_worktree` to take connection info parameter | ||
| 175 | |||
| 176 | ### Phase 3: Refactor Project | ||
| 177 | |||
| 178 | 1. Add `worktree_ids: HashSet<WorktreeId>` field | ||
| 179 | 2. Change `worktree_store` from owned to shared reference | ||
| 180 | 3. Update all methods that iterate worktrees to filter by `worktree_ids` | ||
| 181 | 4. Move DapStore and TaskStore creation into Project (keep them per-workspace) | ||
| 182 | 5. Add references to shared stores (BufferStore, GitStore, etc.) | ||
| 183 | |||
| 184 | ### Phase 4: Refactor Store Creation | ||
| 185 | |||
| 186 | 1. Move BufferStore, GitStore, LspStore, ImageStore, BreakpointStore creation to MultiWorkspace | ||
| 187 | 2. Update these stores to work with the shared WorktreeStore | ||
| 188 | 3. Ensure stores handle worktrees being added/removed dynamically | ||
| 189 | |||
| 190 | ### Phase 5: Update Collaboration | ||
| 191 | |||
| 192 | 1. Add sharing validation (prevent double-sharing of worktrees) | ||
| 193 | 2. Update `share_project` to work with the new architecture | ||
| 194 | 3. Test collaboration scenarios with multiple workspaces | ||
| 195 | |||
| 196 | ### Phase 6: Update Consumers | ||
| 197 | |||
| 198 | Many places call `workspace.project()` and expect to access stores. Audit and update: | ||
| 199 | - Panels (AgentPanel, GitPanel, etc.) | ||
| 200 | - Activity indicator | ||
| 201 | - File opening code paths | ||
| 202 | - Search functionality | ||
| 203 | |||
| 204 | ## Key Files to Modify | ||
| 205 | |||
| 206 | - `crates/project/src/project.rs` - Major refactor | ||
| 207 | - `crates/project/src/worktree_store.rs` - Remove state enum, add explicit methods | ||
| 208 | - `crates/project/src/buffer_store.rs` - May need state simplification | ||
| 209 | - `crates/workspace/src/workspace.rs` - Update to use MultiWorkspace | ||
| 210 | - `crates/call/src/call_impl/room.rs` - Update sharing logic | ||
| 211 | - New file: `crates/workspace/src/multi_workspace.rs` or similar | ||
| 212 | |||
| 213 | ## Testing Considerations | ||
| 214 | |||
| 215 | 1. **Multi-workspace scenarios**: Create tests where two workspaces share worktrees | ||
| 216 | 2. **Edit propagation**: Verify edits in one workspace appear in another | ||
| 217 | 3. **Collaboration with shared worktrees**: Test sharing when worktrees overlap | ||
| 218 | 4. **Mixed local/remote**: Test WorktreeStore with both local and SSH worktrees | ||
| 219 | 5. **Worktree lifecycle**: Test closing workspaces and worktree cleanup | ||
| 220 | |||
| 221 | ## Open Questions for Implementation | ||
| 222 | 202 | ||
| 223 | 1. Should MultiWorkspace be global (one per app) or per-window? | 203 | - `crates/workspace/src/workspace.rs` - Contains `MultiWorkspace`, will contain `Workspace` |
| 224 | 2. How should we handle the transition for existing serialized workspaces? | 204 | - `crates/workspace/src/single_workspace.rs` - Stub module (created) |
| 225 | 3. Should there be UI for managing worktrees across workspaces? | 205 | - `crates/project/src/project.rs` - Will add `worktree_ids` filter |
| 206 | - `crates/project/src/worktree_store.rs` - Will remove `WorktreeStoreState` enum | ||
| 226 | 207 | ||
| 227 | ## References | 208 | ## Current Status |
| 228 | 209 | ||
| 229 | - Original detailed plan: `threads-sidebar-plan.md` (some concepts superseded) | 210 | - [x] Rename `Workspace` → `MultiWorkspace` (done) |
| 230 | - Comment in `crates/project/src/project.rs` lines 162-172 showing target architecture | 211 | - [x] Create stub `single_workspace::Workspace` module (done) |
| 231 | - Current Project::local() and Project::remote() constructors show store creation patterns | 212 | - [ ] Extract UI/pane/panel ownership from `MultiWorkspace` into `Workspace` |
| 213 | - [ ] Pass `WorktreeStore` from `MultiWorkspace` down to `Project` | ||
| 214 | - [ ] **MERGE POINT** | ||
| 215 | - [ ] Add second `Workspace` support | ||
| 216 | - [ ] Refactor stores as needed for sharing | ||
| @@ -1,1067 +0,0 @@ | |||
| 1 | # Phase 2: MultiWorkspace Implementation Plan | ||
| 2 | |||
| 3 | This document details Phase 2 of the MultiWorkspace Agent V2 Refactor. Phase 1 (GlobalThreadStore Infrastructure) is a prerequisite. | ||
| 4 | |||
| 5 | ## Overview | ||
| 6 | |||
| 7 | Enable the workspace to swap between different ThreadWorktrees contexts while keeping state in memory. **Key insight:** Project stays singular per window; we swap which ThreadWorktrees group is active. | ||
| 8 | |||
| 9 | ### Goals | ||
| 10 | |||
| 11 | 1. Create `ThreadWorktrees` as a scoped lens into `Project` for panels | ||
| 12 | 2. Implement `WorkspaceState` to store per-thread UI state (panes, panels) | ||
| 13 | 3. Enable switching between thread contexts without losing state | ||
| 14 | 4. Migrate existing panels from `Entity<Project>` to `Entity<ThreadWorktrees>` | ||
| 15 | |||
| 16 | ### Dependencies | ||
| 17 | |||
| 18 | - **Phase 1 Complete**: `GlobalThreadStore` with project paths stored per thread | ||
| 19 | - **Feature Flag**: All changes gated behind `agent-v2` feature flag | ||
| 20 | |||
| 21 | --- | ||
| 22 | |||
| 23 | ## 2.1 New Concept: ThreadWorktrees | ||
| 24 | |||
| 25 | ### ThreadWorktreesId | ||
| 26 | |||
| 27 | The identity that ties a thread to workspace-level state: | ||
| 28 | |||
| 29 | ```rust | ||
| 30 | // New file: crates/project/src/thread_worktrees.rs | ||
| 31 | |||
| 32 | use gpui::{App, Context, Entity, EventEmitter, Task, WeakEntity}; | ||
| 33 | use collections::HashSet; | ||
| 34 | use std::path::PathBuf; | ||
| 35 | use anyhow::{Result, anyhow}; | ||
| 36 | |||
| 37 | /// Identifies a ThreadWorktrees group - glues thread identity to workspace identity | ||
| 38 | #[derive(Clone, Hash, Eq, PartialEq, Debug)] | ||
| 39 | pub struct ThreadWorktreesId { | ||
| 40 | /// None for "default" (no thread active) | ||
| 41 | pub session_id: Option<acp::SessionId>, | ||
| 42 | pub workspace_db_id: WorkspaceId, | ||
| 43 | } | ||
| 44 | |||
| 45 | impl ThreadWorktreesId { | ||
| 46 | pub fn default_for_workspace(workspace_db_id: WorkspaceId) -> Self { | ||
| 47 | Self { | ||
| 48 | session_id: None, | ||
| 49 | workspace_db_id, | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn for_thread(session_id: acp::SessionId, workspace_db_id: WorkspaceId) -> Self { | ||
| 54 | Self { | ||
| 55 | session_id: Some(session_id), | ||
| 56 | workspace_db_id, | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | ``` | ||
| 61 | |||
| 62 | ### ThreadWorktrees Struct | ||
| 63 | |||
| 64 | ```rust | ||
| 65 | /// Groups worktrees that belong to a specific thread context. | ||
| 66 | /// Acts as a filtered lens into Project for panels. | ||
| 67 | pub struct ThreadWorktrees { | ||
| 68 | id: ThreadWorktreesId, | ||
| 69 | project: Entity<Project>, | ||
| 70 | worktree_ids: HashSet<WorktreeId>, | ||
| 71 | folder_paths: Vec<PathBuf>, // Absolute paths for persistence | ||
| 72 | _subscriptions: Vec<gpui::Subscription>, | ||
| 73 | } | ||
| 74 | ``` | ||
| 75 | |||
| 76 | ### Project Additions | ||
| 77 | |||
| 78 | Modify `crates/project/src/project.rs`: | ||
| 79 | |||
| 80 | ```rust | ||
| 81 | pub struct Project { | ||
| 82 | // EXISTING: all worktrees for all live threads in this window | ||
| 83 | worktree_store: Entity<WorktreeStore>, | ||
| 84 | |||
| 85 | // NEW: Groups worktrees by thread | ||
| 86 | thread_worktrees: HashMap<ThreadWorktreesId, Entity<ThreadWorktrees>>, | ||
| 87 | |||
| 88 | // NEW: Currently active thread context | ||
| 89 | active_thread_worktrees_id: Option<ThreadWorktreesId>, | ||
| 90 | |||
| 91 | // ... existing fields ... | ||
| 92 | } | ||
| 93 | |||
| 94 | impl Project { | ||
| 95 | /// Get or create ThreadWorktrees for a given ID | ||
| 96 | pub fn thread_worktrees( | ||
| 97 | &mut self, | ||
| 98 | id: &ThreadWorktreesId, | ||
| 99 | cx: &mut Context<Self>, | ||
| 100 | ) -> Entity<ThreadWorktrees> { | ||
| 101 | if let Some(tw) = self.thread_worktrees.get(id) { | ||
| 102 | return tw.clone(); | ||
| 103 | } | ||
| 104 | |||
| 105 | // Create new ThreadWorktrees with worktrees based on thread's folder_paths | ||
| 106 | let worktree_ids = self.worktree_ids_for_thread(id, cx); | ||
| 107 | let thread_worktrees = cx.new(|cx| { | ||
| 108 | ThreadWorktrees::new(id.clone(), cx.entity().clone(), worktree_ids, cx) | ||
| 109 | }); | ||
| 110 | self.thread_worktrees.insert(id.clone(), thread_worktrees.clone()); | ||
| 111 | thread_worktrees | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Set the active thread context | ||
| 115 | pub fn set_active_thread_worktrees( | ||
| 116 | &mut self, | ||
| 117 | id: Option<ThreadWorktreesId>, | ||
| 118 | cx: &mut Context<Self>, | ||
| 119 | ) { | ||
| 120 | self.active_thread_worktrees_id = id; | ||
| 121 | cx.notify(); | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Get the currently active ThreadWorktrees | ||
| 125 | pub fn active_thread_worktrees(&self, cx: &App) -> Option<Entity<ThreadWorktrees>> { | ||
| 126 | self.active_thread_worktrees_id | ||
| 127 | .as_ref() | ||
| 128 | .and_then(|id| self.thread_worktrees.get(id).cloned()) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | ``` | ||
| 132 | |||
| 133 | ### Worktree Lifecycle | ||
| 134 | |||
| 135 | - Worktrees are automatically created/loaded when opening a thread | ||
| 136 | - Live threads (running in background) keep their worktrees loaded | ||
| 137 | - Worktrees shared between threads (e.g., /cloud in threads A and B) exist once in Project but are referenced by multiple ThreadWorktrees | ||
| 138 | |||
| 139 | --- | ||
| 140 | |||
| 141 | ## 2.2 ThreadWorktrees as Panel API Surface | ||
| 142 | |||
| 143 | Panels currently hold `Entity<Project>` and call methods like `project.read(cx).git_store()`. In multi-workspace, panels need **scoped access** - GitPanel for ThreadWorktrees A shouldn't see repos from ThreadWorktrees B. | ||
| 144 | |||
| 145 | ### Current Panel Pattern (to migrate from) | ||
| 146 | |||
| 147 | Based on existing code analysis: | ||
| 148 | |||
| 149 | ```rust | ||
| 150 | // crates/git_ui/src/git_panel.rs - Current | ||
| 151 | pub struct GitPanel { | ||
| 152 | pub(crate) project: Entity<Project>, | ||
| 153 | pub(crate) active_repository: Option<Entity<Repository>>, | ||
| 154 | // ... | ||
| 155 | } | ||
| 156 | |||
| 157 | // crates/project_panel/src/project_panel.rs - Current | ||
| 158 | pub struct ProjectPanel { | ||
| 159 | project: Entity<Project>, | ||
| 160 | fs: Arc<dyn Fs>, | ||
| 161 | // ... | ||
| 162 | } | ||
| 163 | ``` | ||
| 164 | |||
| 165 | ### ThreadWorktrees API Implementation | ||
| 166 | |||
| 167 | ```rust | ||
| 168 | // crates/project/src/thread_worktrees.rs | ||
| 169 | |||
| 170 | impl ThreadWorktrees { | ||
| 171 | pub fn new( | ||
| 172 | id: ThreadWorktreesId, | ||
| 173 | project: Entity<Project>, | ||
| 174 | worktree_ids: HashSet<WorktreeId>, | ||
| 175 | cx: &mut Context<Self>, | ||
| 176 | ) -> Self { | ||
| 177 | let git_store = project.read(cx).git_store().clone(); | ||
| 178 | let worktree_store = project.read(cx).worktree_store.clone(); | ||
| 179 | let mut subscriptions = Vec::new(); | ||
| 180 | |||
| 181 | // Subscribe to git_store and filter events | ||
| 182 | subscriptions.push(cx.subscribe(&git_store, { | ||
| 183 | let worktree_ids = worktree_ids.clone(); | ||
| 184 | move |this, _store, event, cx| { | ||
| 185 | this.handle_git_store_event(event, &worktree_ids, cx); | ||
| 186 | } | ||
| 187 | })); | ||
| 188 | |||
| 189 | // Subscribe to worktree_store for worktree changes | ||
| 190 | subscriptions.push(cx.subscribe(&worktree_store, { | ||
| 191 | let worktree_ids = worktree_ids.clone(); | ||
| 192 | move |this, _store, event, cx| { | ||
| 193 | this.handle_worktree_store_event(event, &worktree_ids, cx); | ||
| 194 | } | ||
| 195 | })); | ||
| 196 | |||
| 197 | Self { | ||
| 198 | id, | ||
| 199 | project, | ||
| 200 | worktree_ids, | ||
| 201 | folder_paths: Vec::new(), | ||
| 202 | _subscriptions: subscriptions, | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | // === Identity === | ||
| 207 | |||
| 208 | pub fn id(&self) -> &ThreadWorktreesId { | ||
| 209 | &self.id | ||
| 210 | } | ||
| 211 | |||
| 212 | pub fn project(&self) -> &Entity<Project> { | ||
| 213 | &self.project | ||
| 214 | } | ||
| 215 | |||
| 216 | // === Scoped Worktree Access === | ||
| 217 | |||
| 218 | pub fn visible_worktrees<'a>(&self, cx: &'a App) -> impl Iterator<Item = Entity<Worktree>> + 'a { | ||
| 219 | let owned_ids = self.worktree_ids.clone(); | ||
| 220 | self.project | ||
| 221 | .read(cx) | ||
| 222 | .visible_worktrees(cx) | ||
| 223 | .filter(move |wt| owned_ids.contains(&wt.read(cx).id())) | ||
| 224 | } | ||
| 225 | |||
| 226 | pub fn worktree_for_id(&self, id: WorktreeId, cx: &App) -> Option<Entity<Worktree>> { | ||
| 227 | if self.worktree_ids.contains(&id) { | ||
| 228 | self.project.read(cx).worktree_for_id(id, cx) | ||
| 229 | } else { | ||
| 230 | None | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | pub fn worktree_count(&self) -> usize { | ||
| 235 | self.worktree_ids.len() | ||
| 236 | } | ||
| 237 | |||
| 238 | // === Scoped Git Access === | ||
| 239 | |||
| 240 | pub fn active_repository(&self, cx: &App) -> Option<Entity<Repository>> { | ||
| 241 | let repo = self.project.read(cx).active_repository(cx)?; | ||
| 242 | let worktree_id = repo.read(cx).worktree_id(); | ||
| 243 | self.worktree_ids.contains(&worktree_id).then_some(repo) | ||
| 244 | } | ||
| 245 | |||
| 246 | /// Returns the shared git_store - events are filtered via subscriptions | ||
| 247 | pub fn git_store(&self, cx: &App) -> Entity<GitStore> { | ||
| 248 | self.project.read(cx).git_store().clone() | ||
| 249 | } | ||
| 250 | |||
| 251 | fn repo_in_scope(&self, repo_id: RepositoryId, cx: &App) -> bool { | ||
| 252 | let git_store = self.project.read(cx).git_store(); | ||
| 253 | git_store | ||
| 254 | .read(cx) | ||
| 255 | .repository(repo_id) | ||
| 256 | .map(|repo| self.worktree_ids.contains(&repo.read(cx).worktree_id())) | ||
| 257 | .unwrap_or(false) | ||
| 258 | } | ||
| 259 | |||
| 260 | // === Pass-through for Global State === | ||
| 261 | |||
| 262 | pub fn is_via_collab(&self, cx: &App) -> bool { | ||
| 263 | self.project.read(cx).is_via_collab() | ||
| 264 | } | ||
| 265 | |||
| 266 | pub fn is_read_only(&self, cx: &App) -> bool { | ||
| 267 | self.project.read(cx).is_read_only(cx) | ||
| 268 | } | ||
| 269 | |||
| 270 | pub fn languages(&self, cx: &App) -> Arc<LanguageRegistry> { | ||
| 271 | self.project.read(cx).languages().clone() | ||
| 272 | } | ||
| 273 | |||
| 274 | pub fn path_style(&self, cx: &App) -> PathStyle { | ||
| 275 | self.project.read(cx).path_style(cx) | ||
| 276 | } | ||
| 277 | |||
| 278 | pub fn fs(&self, cx: &App) -> Arc<dyn Fs> { | ||
| 279 | self.project.read(cx).fs.clone() | ||
| 280 | } | ||
| 281 | |||
| 282 | // === Scoped Buffer Operations === | ||
| 283 | |||
| 284 | /// Shared buffer_store - buffers are deduplicated across threads | ||
| 285 | pub fn buffer_store(&self, cx: &App) -> Entity<BufferStore> { | ||
| 286 | self.project.read(cx).buffer_store().clone() | ||
| 287 | } | ||
| 288 | |||
| 289 | /// Open buffer with scope validation (workflow guard, not isolation) | ||
| 290 | pub fn open_buffer( | ||
| 291 | &self, | ||
| 292 | path: ProjectPath, | ||
| 293 | cx: &mut Context<Self>, | ||
| 294 | ) -> Task<Result<Entity<Buffer>>> { | ||
| 295 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 296 | return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | ||
| 297 | } | ||
| 298 | self.project | ||
| 299 | .update(cx, |project, cx| project.open_buffer(path, cx)) | ||
| 300 | } | ||
| 301 | |||
| 302 | // === Scoped File Operations === | ||
| 303 | |||
| 304 | pub fn delete_file( | ||
| 305 | &self, | ||
| 306 | path: ProjectPath, | ||
| 307 | trash: bool, | ||
| 308 | cx: &mut Context<Self>, | ||
| 309 | ) -> Option<Task<Result<()>>> { | ||
| 310 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 311 | return None; | ||
| 312 | } | ||
| 313 | self.project | ||
| 314 | .update(cx, |project, cx| project.delete_file(path, trash, cx)) | ||
| 315 | } | ||
| 316 | |||
| 317 | pub fn create_file( | ||
| 318 | &self, | ||
| 319 | path: ProjectPath, | ||
| 320 | is_dir: bool, | ||
| 321 | cx: &mut Context<Self>, | ||
| 322 | ) -> Option<Task<Result<CreatedEntry>>> { | ||
| 323 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 324 | return None; | ||
| 325 | } | ||
| 326 | self.project | ||
| 327 | .update(cx, |project, cx| project.create_file(path, is_dir, cx)) | ||
| 328 | } | ||
| 329 | |||
| 330 | pub fn rename_entry( | ||
| 331 | &self, | ||
| 332 | entry_id: ProjectEntryId, | ||
| 333 | new_path: impl Into<Arc<Path>>, | ||
| 334 | cx: &mut Context<Self>, | ||
| 335 | ) -> Option<Task<Result<CreatedEntry>>> { | ||
| 336 | // Verify entry belongs to our worktrees | ||
| 337 | let worktree_id = self.project.read(cx).entry_worktree_id(entry_id)?; | ||
| 338 | if !self.worktree_ids.contains(&worktree_id) { | ||
| 339 | return None; | ||
| 340 | } | ||
| 341 | self.project.update(cx, |project, cx| { | ||
| 342 | project.rename_entry(entry_id, new_path, cx) | ||
| 343 | }) | ||
| 344 | } | ||
| 345 | } | ||
| 346 | ``` | ||
| 347 | |||
| 348 | ### ThreadWorktrees Events | ||
| 349 | |||
| 350 | ```rust | ||
| 351 | #[derive(Clone, Debug)] | ||
| 352 | pub enum ThreadWorktreesEvent { | ||
| 353 | /// Active repository changed (filtered to this scope) | ||
| 354 | ActiveRepositoryChanged(Option<Entity<Repository>>), | ||
| 355 | /// A repository in this scope was updated | ||
| 356 | RepositoryUpdated(RepositoryId, RepositoryEvent), | ||
| 357 | /// A worktree in this scope was added | ||
| 358 | WorktreeAdded(WorktreeId), | ||
| 359 | /// A worktree in this scope was removed | ||
| 360 | WorktreeRemoved(WorktreeId), | ||
| 361 | /// Entries updated in a worktree in this scope | ||
| 362 | WorktreeEntriesUpdated(WorktreeId, UpdatedEntriesSet), | ||
| 363 | } | ||
| 364 | |||
| 365 | impl EventEmitter<ThreadWorktreesEvent> for ThreadWorktrees {} | ||
| 366 | |||
| 367 | impl ThreadWorktrees { | ||
| 368 | fn handle_git_store_event( | ||
| 369 | &mut self, | ||
| 370 | event: &GitStoreEvent, | ||
| 371 | worktree_ids: &HashSet<WorktreeId>, | ||
| 372 | cx: &mut Context<Self>, | ||
| 373 | ) { | ||
| 374 | match event { | ||
| 375 | GitStoreEvent::ActiveRepositoryChanged(repo_id) => { | ||
| 376 | // Only emit if repo is in our worktrees | ||
| 377 | let filtered_repo = repo_id.as_ref().and_then(|id| { | ||
| 378 | let git_store = self.project.read(cx).git_store(); | ||
| 379 | let repo = git_store.read(cx).repository(*id)?; | ||
| 380 | worktree_ids | ||
| 381 | .contains(&repo.read(cx).worktree_id()) | ||
| 382 | .then_some(repo) | ||
| 383 | }); | ||
| 384 | cx.emit(ThreadWorktreesEvent::ActiveRepositoryChanged(filtered_repo)); | ||
| 385 | } | ||
| 386 | GitStoreEvent::RepositoryUpdated(repo_id, event, _) => { | ||
| 387 | if self.repo_in_scope(*repo_id, cx) { | ||
| 388 | cx.emit(ThreadWorktreesEvent::RepositoryUpdated(*repo_id, event.clone())); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | _ => {} | ||
| 392 | } | ||
| 393 | } | ||
| 394 | |||
| 395 | fn handle_worktree_store_event( | ||
| 396 | &mut self, | ||
| 397 | event: &WorktreeStoreEvent, | ||
| 398 | worktree_ids: &HashSet<WorktreeId>, | ||
| 399 | cx: &mut Context<Self>, | ||
| 400 | ) { | ||
| 401 | match event { | ||
| 402 | WorktreeStoreEvent::WorktreeAdded(wt) => { | ||
| 403 | let id = wt.read(cx).id(); | ||
| 404 | if worktree_ids.contains(&id) { | ||
| 405 | cx.emit(ThreadWorktreesEvent::WorktreeAdded(id)); | ||
| 406 | } | ||
| 407 | } | ||
| 408 | WorktreeStoreEvent::WorktreeRemoved(_, id) => { | ||
| 409 | if worktree_ids.contains(id) { | ||
| 410 | cx.emit(ThreadWorktreesEvent::WorktreeRemoved(*id)); | ||
| 411 | } | ||
| 412 | } | ||
| 413 | WorktreeStoreEvent::WorktreeEntriesUpdated { worktree_id, entries } => { | ||
| 414 | if worktree_ids.contains(worktree_id) { | ||
| 415 | cx.emit(ThreadWorktreesEvent::WorktreeEntriesUpdated( | ||
| 416 | *worktree_id, | ||
| 417 | entries.clone(), | ||
| 418 | )); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | _ => {} | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | ``` | ||
| 426 | |||
| 427 | ### API Categories Reference | ||
| 428 | |||
| 429 | | Category | Examples | ThreadWorktrees Behavior | | ||
| 430 | |----------|----------|-------------------------| | ||
| 431 | | Worktree-scoped | `visible_worktrees()`, `worktree_for_id()` | Filtered to owned worktrees | | ||
| 432 | | Git-scoped | `active_repository()`, `git_init()` | Filtered by worktree ownership | | ||
| 433 | | File operations | `open_buffer()`, `delete_file()` | Validated against scope | | ||
| 434 | | Global/shared | `is_via_collab()`, `languages()`, `path_style()`, `fs()` | Pass-through to Project | | ||
| 435 | | Buffer store | `buffer_store()`, `open_buffer()` | Shared (deduped by ProjectPath) | | ||
| 436 | |||
| 437 | --- | ||
| 438 | |||
| 439 | ## 2.3 WorkspaceState per ThreadWorktrees | ||
| 440 | |||
| 441 | Modify `crates/workspace/src/workspace.rs`: | ||
| 442 | |||
| 443 | ### WorkspaceState Struct | ||
| 444 | |||
| 445 | ```rust | ||
| 446 | /// State for a specific ThreadWorktrees context. | ||
| 447 | /// Each thread gets its own set of panes and panel instances. | ||
| 448 | pub struct WorkspaceState { | ||
| 449 | thread_worktrees_id: ThreadWorktreesId, | ||
| 450 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 451 | |||
| 452 | // Pane state (swapped when switching threads) | ||
| 453 | center: PaneGroup, | ||
| 454 | panes: Vec<Entity<Pane>>, | ||
| 455 | panes_by_item: HashMap<EntityId, WeakEntity<Pane>>, | ||
| 456 | active_pane: Entity<Pane>, | ||
| 457 | last_active_center_pane: Option<WeakEntity<Pane>>, | ||
| 458 | |||
| 459 | // Panel instances - each ThreadWorktrees gets its own | ||
| 460 | panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | ||
| 461 | |||
| 462 | // Serialization | ||
| 463 | last_serialized: Option<Instant>, | ||
| 464 | } | ||
| 465 | |||
| 466 | impl WorkspaceState { | ||
| 467 | pub fn new( | ||
| 468 | thread_worktrees_id: ThreadWorktreesId, | ||
| 469 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 470 | window: &mut Window, | ||
| 471 | cx: &mut Context<Workspace>, | ||
| 472 | ) -> Self { | ||
| 473 | let pane = cx.new(|cx| { | ||
| 474 | Pane::new( | ||
| 475 | cx.entity().downgrade(), | ||
| 476 | /* project */ thread_worktrees.read(cx).project().clone(), | ||
| 477 | Default::default(), | ||
| 478 | None, | ||
| 479 | NewFile.boxed_clone(), | ||
| 480 | window, | ||
| 481 | cx, | ||
| 482 | ) | ||
| 483 | }); | ||
| 484 | |||
| 485 | Self { | ||
| 486 | thread_worktrees_id, | ||
| 487 | thread_worktrees, | ||
| 488 | center: PaneGroup::new(pane.clone()), | ||
| 489 | panes: vec![pane.clone()], | ||
| 490 | panes_by_item: HashMap::default(), | ||
| 491 | active_pane: pane, | ||
| 492 | last_active_center_pane: None, | ||
| 493 | panel_instances: HashMap::default(), | ||
| 494 | last_serialized: None, | ||
| 495 | } | ||
| 496 | } | ||
| 497 | |||
| 498 | pub fn thread_worktrees(&self) -> &Entity<ThreadWorktrees> { | ||
| 499 | &self.thread_worktrees | ||
| 500 | } | ||
| 501 | } | ||
| 502 | ``` | ||
| 503 | |||
| 504 | ### Modified Workspace Struct | ||
| 505 | |||
| 506 | ```rust | ||
| 507 | pub struct Workspace { | ||
| 508 | project: Entity<Project>, // Still singular per window | ||
| 509 | |||
| 510 | // NEW: State per ThreadWorktrees | ||
| 511 | workspace_states: HashMap<ThreadWorktreesId, WorkspaceState>, | ||
| 512 | active_workspace_state_id: Option<ThreadWorktreesId>, | ||
| 513 | |||
| 514 | // Docks stay constant - panel contents come from active WorkspaceState | ||
| 515 | left_dock: Entity<Dock>, | ||
| 516 | bottom_dock: Entity<Dock>, | ||
| 517 | right_dock: Entity<Dock>, | ||
| 518 | |||
| 519 | // These stay constant across thread switches | ||
| 520 | status_bar: Entity<StatusBar>, | ||
| 521 | modal_layer: Entity<ModalLayer>, | ||
| 522 | toast_layer: Entity<ToastLayer>, | ||
| 523 | titlebar_item: Option<AnyView>, | ||
| 524 | notifications: Notifications, | ||
| 525 | |||
| 526 | // Global panels (not per-thread) | ||
| 527 | global_panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | ||
| 528 | |||
| 529 | // ... existing fields (weak_self, app_state, etc.) ... | ||
| 530 | } | ||
| 531 | ``` | ||
| 532 | |||
| 533 | --- | ||
| 534 | |||
| 535 | ## 2.4 Switching Active ThreadWorktrees | ||
| 536 | |||
| 537 | ### Implementation | ||
| 538 | |||
| 539 | ```rust | ||
| 540 | impl Workspace { | ||
| 541 | /// Switch to a different thread context | ||
| 542 | pub fn switch_to_thread( | ||
| 543 | &mut self, | ||
| 544 | thread_worktrees_id: ThreadWorktreesId, | ||
| 545 | window: &mut Window, | ||
| 546 | cx: &mut Context<Self>, | ||
| 547 | ) { | ||
| 548 | // 1. Save current state (implicit - already in workspace_states) | ||
| 549 | if let Some(current_id) = &self.active_workspace_state_id { | ||
| 550 | if *current_id == thread_worktrees_id { | ||
| 551 | return; // Already active | ||
| 552 | } | ||
| 553 | } | ||
| 554 | |||
| 555 | // 2. Ensure ThreadWorktrees exists in Project | ||
| 556 | let thread_worktrees = self.project.update(cx, |project, cx| { | ||
| 557 | project.thread_worktrees(&thread_worktrees_id, cx) | ||
| 558 | }); | ||
| 559 | |||
| 560 | // 3. Update Project's active context | ||
| 561 | self.project.update(cx, |project, cx| { | ||
| 562 | project.set_active_thread_worktrees(Some(thread_worktrees_id.clone()), cx); | ||
| 563 | }); | ||
| 564 | |||
| 565 | // 4. Load or create WorkspaceState | ||
| 566 | if !self.workspace_states.contains_key(&thread_worktrees_id) { | ||
| 567 | let state = WorkspaceState::new( | ||
| 568 | thread_worktrees_id.clone(), | ||
| 569 | thread_worktrees.clone(), | ||
| 570 | window, | ||
| 571 | cx, | ||
| 572 | ); | ||
| 573 | self.workspace_states.insert(thread_worktrees_id.clone(), state); | ||
| 574 | |||
| 575 | // Initialize scoped panels for new state | ||
| 576 | self.initialize_scoped_panels_for_state(&thread_worktrees_id, window, cx); | ||
| 577 | } | ||
| 578 | |||
| 579 | // 5. Update active state | ||
| 580 | self.active_workspace_state_id = Some(thread_worktrees_id); | ||
| 581 | |||
| 582 | // 6. Update UI | ||
| 583 | self.update_docks_for_active_state(window, cx); | ||
| 584 | self.update_window_title(window, cx); | ||
| 585 | cx.notify(); | ||
| 586 | } | ||
| 587 | |||
| 588 | /// Get the active WorkspaceState | ||
| 589 | pub fn active_workspace_state(&self) -> Option<&WorkspaceState> { | ||
| 590 | self.active_workspace_state_id | ||
| 591 | .as_ref() | ||
| 592 | .and_then(|id| self.workspace_states.get(id)) | ||
| 593 | } | ||
| 594 | |||
| 595 | pub fn active_workspace_state_mut(&mut self) -> Option<&mut WorkspaceState> { | ||
| 596 | self.active_workspace_state_id | ||
| 597 | .as_ref() | ||
| 598 | .cloned() | ||
| 599 | .and_then(move |id| self.workspace_states.get_mut(&id)) | ||
| 600 | } | ||
| 601 | |||
| 602 | /// Close a thread context and clean up its state | ||
| 603 | pub fn close_thread( | ||
| 604 | &mut self, | ||
| 605 | thread_worktrees_id: &ThreadWorktreesId, | ||
| 606 | window: &mut Window, | ||
| 607 | cx: &mut Context<Self>, | ||
| 608 | ) { | ||
| 609 | // Remove from workspace_states - GPUI reference counting handles cleanup | ||
| 610 | self.workspace_states.remove(thread_worktrees_id); | ||
| 611 | |||
| 612 | // If this was the active state, switch to default or another thread | ||
| 613 | if self.active_workspace_state_id.as_ref() == Some(thread_worktrees_id) { | ||
| 614 | let fallback = self.workspace_states.keys().next().cloned(); | ||
| 615 | if let Some(fallback_id) = fallback { | ||
| 616 | self.switch_to_thread(fallback_id, window, cx); | ||
| 617 | } else { | ||
| 618 | self.active_workspace_state_id = None; | ||
| 619 | cx.notify(); | ||
| 620 | } | ||
| 621 | } | ||
| 622 | } | ||
| 623 | |||
| 624 | fn update_docks_for_active_state(&mut self, window: &mut Window, cx: &mut Context<Self>) { | ||
| 625 | let Some(state) = self.active_workspace_state() else { | ||
| 626 | return; | ||
| 627 | }; | ||
| 628 | |||
| 629 | // Update each dock to show panels from the active WorkspaceState | ||
| 630 | for (type_id, panel) in &state.panel_instances { | ||
| 631 | // Find which dock this panel belongs to and update it | ||
| 632 | let position = panel.position(window, cx); | ||
| 633 | let dock = match position { | ||
| 634 | DockPosition::Left => &self.left_dock, | ||
| 635 | DockPosition::Bottom => &self.bottom_dock, | ||
| 636 | DockPosition::Right => &self.right_dock, | ||
| 637 | }; | ||
| 638 | // Dock will need API to swap panel instance | ||
| 639 | // dock.update(cx, |dock, cx| dock.set_panel_instance(*type_id, panel.clone(), cx)); | ||
| 640 | } | ||
| 641 | } | ||
| 642 | } | ||
| 643 | ``` | ||
| 644 | |||
| 645 | --- | ||
| 646 | |||
| 647 | ## 2.5 Panel Instance Management | ||
| 648 | |||
| 649 | ### Scoped vs Global Panels | ||
| 650 | |||
| 651 | **SCOPED (per-ThreadWorktrees)** - stored in `WorkspaceState.panel_instances`: | ||
| 652 | |||
| 653 | | Panel | Reason | | ||
| 654 | |-------|--------| | ||
| 655 | | `ProjectPanel` | Shows worktree file trees | | ||
| 656 | | `GitPanel` | Shows repos tied to worktrees | | ||
| 657 | | `OutlinePanel` | Shows context for files in worktrees | | ||
| 658 | | `DebugPanel` | Debug sessions are project-specific | | ||
| 659 | | `TerminalPanel` | Terminals have working directories in worktrees | | ||
| 660 | | `AgentPanel` | Thread-specific conversation | | ||
| 661 | |||
| 662 | **GLOBAL (shared)** - stored on `Workspace.global_panel_instances`: | ||
| 663 | |||
| 664 | | Panel | Reason | | ||
| 665 | |-------|--------| | ||
| 666 | | `CollabPanel` | Collaborators, channels, contacts - user's social state | | ||
| 667 | | `NotificationPanel` | User's notifications across all projects | | ||
| 668 | |||
| 669 | ### Panel Creation | ||
| 670 | |||
| 671 | ```rust | ||
| 672 | impl WorkspaceState { | ||
| 673 | /// Get or create a scoped panel for this WorkspaceState | ||
| 674 | pub fn get_or_create_panel<P: Panel>( | ||
| 675 | &mut self, | ||
| 676 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 677 | window: &mut Window, | ||
| 678 | cx: &mut App, | ||
| 679 | ) -> Entity<P> | ||
| 680 | where | ||
| 681 | P: Panel + PanelFromThreadWorktrees, | ||
| 682 | { | ||
| 683 | let type_id = TypeId::of::<P>(); | ||
| 684 | if let Some(panel) = self.panel_instances.get(&type_id) { | ||
| 685 | return panel | ||
| 686 | .to_any() | ||
| 687 | .downcast::<P>() | ||
| 688 | .expect("Panel type mismatch"); | ||
| 689 | } | ||
| 690 | |||
| 691 | let panel = cx.new(|cx| P::new(thread_worktrees, window, cx)); | ||
| 692 | self.panel_instances.insert(type_id, Box::new(panel.clone())); | ||
| 693 | panel | ||
| 694 | } | ||
| 695 | } | ||
| 696 | |||
| 697 | /// Trait for panels that can be constructed from ThreadWorktrees | ||
| 698 | pub trait PanelFromThreadWorktrees: Panel { | ||
| 699 | fn new( | ||
| 700 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 701 | window: &mut Window, | ||
| 702 | cx: &mut Context<Self>, | ||
| 703 | ) -> Self; | ||
| 704 | } | ||
| 705 | ``` | ||
| 706 | |||
| 707 | ### Initialize Scoped Panels | ||
| 708 | |||
| 709 | ```rust | ||
| 710 | impl Workspace { | ||
| 711 | fn initialize_scoped_panels_for_state( | ||
| 712 | &mut self, | ||
| 713 | thread_worktrees_id: &ThreadWorktreesId, | ||
| 714 | window: &mut Window, | ||
| 715 | cx: &mut Context<Self>, | ||
| 716 | ) { | ||
| 717 | let Some(state) = self.workspace_states.get_mut(thread_worktrees_id) else { | ||
| 718 | return; | ||
| 719 | }; | ||
| 720 | let thread_worktrees = state.thread_worktrees.clone(); | ||
| 721 | |||
| 722 | // Initialize all scoped panels | ||
| 723 | // Note: This follows the pattern in zed.rs initialize_panels but per-state | ||
| 724 | |||
| 725 | // ProjectPanel | ||
| 726 | let project_panel = cx.new(|cx| { | ||
| 727 | ProjectPanel::new_from_thread_worktrees(thread_worktrees.clone(), window, cx) | ||
| 728 | }); | ||
| 729 | state.panel_instances.insert( | ||
| 730 | TypeId::of::<ProjectPanel>(), | ||
| 731 | Box::new(project_panel), | ||
| 732 | ); | ||
| 733 | |||
| 734 | // GitPanel | ||
| 735 | let git_panel = cx.new(|cx| { | ||
| 736 | GitPanel::new_from_thread_worktrees(thread_worktrees.clone(), window, cx) | ||
| 737 | }); | ||
| 738 | state.panel_instances.insert( | ||
| 739 | TypeId::of::<GitPanel>(), | ||
| 740 | Box::new(git_panel), | ||
| 741 | ); | ||
| 742 | |||
| 743 | // OutlinePanel, DebugPanel, TerminalPanel, AgentPanel... | ||
| 744 | } | ||
| 745 | } | ||
| 746 | ``` | ||
| 747 | |||
| 748 | --- | ||
| 749 | |||
| 750 | ## 2.6 Panel Migration Pattern | ||
| 751 | |||
| 752 | ### Before/After Comparison | ||
| 753 | |||
| 754 | ```rust | ||
| 755 | // BEFORE: Panel holds Project directly | ||
| 756 | pub struct GitPanel { | ||
| 757 | project: Entity<Project>, | ||
| 758 | active_repository: Option<Entity<Repository>>, | ||
| 759 | // ... | ||
| 760 | } | ||
| 761 | |||
| 762 | impl GitPanel { | ||
| 763 | fn new(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) -> Entity<Self> { | ||
| 764 | let project = workspace.project().clone(); | ||
| 765 | let git_store = project.read(cx).git_store().clone(); | ||
| 766 | let active_repository = project.read(cx).active_repository(cx); | ||
| 767 | |||
| 768 | cx.new(|cx| { | ||
| 769 | // Subscribe to git_store directly | ||
| 770 | cx.subscribe(&git_store, |this, _, event, cx| { | ||
| 771 | // Handle ALL events, no filtering | ||
| 772 | match event { | ||
| 773 | GitStoreEvent::ActiveRepositoryChanged(repo) => { | ||
| 774 | this.active_repository = repo.clone(); | ||
| 775 | cx.notify(); | ||
| 776 | } | ||
| 777 | // ... | ||
| 778 | } | ||
| 779 | }).detach(); | ||
| 780 | |||
| 781 | Self { project, active_repository, /* ... */ } | ||
| 782 | }) | ||
| 783 | } | ||
| 784 | } | ||
| 785 | ``` | ||
| 786 | |||
| 787 | ```rust | ||
| 788 | // AFTER: Panel holds ThreadWorktrees | ||
| 789 | pub struct GitPanel { | ||
| 790 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 791 | active_repository: Option<Entity<Repository>>, | ||
| 792 | // ... | ||
| 793 | } | ||
| 794 | |||
| 795 | impl GitPanel { | ||
| 796 | fn new_from_thread_worktrees( | ||
| 797 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 798 | window: &mut Window, | ||
| 799 | cx: &mut Context<Self>, | ||
| 800 | ) -> Self { | ||
| 801 | let active_repository = thread_worktrees.read(cx).active_repository(cx); | ||
| 802 | |||
| 803 | // Subscribe to ThreadWorktrees events (pre-filtered) | ||
| 804 | cx.subscribe(&thread_worktrees, |this, _, event, cx| { | ||
| 805 | match event { | ||
| 806 | ThreadWorktreesEvent::ActiveRepositoryChanged(repo) => { | ||
| 807 | this.active_repository = repo.clone(); | ||
| 808 | cx.notify(); | ||
| 809 | } | ||
| 810 | ThreadWorktreesEvent::RepositoryUpdated(repo_id, event) => { | ||
| 811 | // Already filtered to our scope | ||
| 812 | this.handle_repo_update(*repo_id, event, cx); | ||
| 813 | } | ||
| 814 | // ... | ||
| 815 | } | ||
| 816 | }).detach(); | ||
| 817 | |||
| 818 | Self { thread_worktrees, active_repository, /* ... */ } | ||
| 819 | } | ||
| 820 | } | ||
| 821 | |||
| 822 | // Method migration example | ||
| 823 | impl GitPanel { | ||
| 824 | // BEFORE | ||
| 825 | fn old_get_repos(&self, cx: &App) -> Vec<Entity<Repository>> { | ||
| 826 | self.project.read(cx).visible_worktrees(cx) | ||
| 827 | .filter_map(|wt| /* get repo */) | ||
| 828 | .collect() | ||
| 829 | } | ||
| 830 | |||
| 831 | // AFTER - same API, scoped results | ||
| 832 | fn get_repos(&self, cx: &App) -> Vec<Entity<Repository>> { | ||
| 833 | self.thread_worktrees.read(cx).visible_worktrees(cx) | ||
| 834 | .filter_map(|wt| /* get repo */) | ||
| 835 | .collect() | ||
| 836 | } | ||
| 837 | } | ||
| 838 | ``` | ||
| 839 | |||
| 840 | ### Migration Checklist Per Panel | ||
| 841 | |||
| 842 | For each panel migrating from `Entity<Project>` to `Entity<ThreadWorktrees>`: | ||
| 843 | |||
| 844 | 1. [ ] Replace `project: Entity<Project>` field with `thread_worktrees: Entity<ThreadWorktrees>` | ||
| 845 | 2. [ ] Update constructor to take `Entity<ThreadWorktrees>` parameter | ||
| 846 | 3. [ ] Replace `cx.subscribe(&git_store, ...)` with `cx.subscribe(&thread_worktrees, ...)` | ||
| 847 | 4. [ ] Update event handlers to use `ThreadWorktreesEvent` variants | ||
| 848 | 5. [ ] Replace `self.project.read(cx).visible_worktrees(cx)` with `self.thread_worktrees.read(cx).visible_worktrees(cx)` | ||
| 849 | 6. [ ] Replace `self.project.read(cx).active_repository(cx)` with `self.thread_worktrees.read(cx).active_repository(cx)` | ||
| 850 | 7. [ ] Keep pass-through methods: `is_via_collab()`, `languages()`, `path_style()`, `fs()` | ||
| 851 | 8. [ ] Add `impl PanelFromThreadWorktrees for Panel` if needed | ||
| 852 | |||
| 853 | --- | ||
| 854 | |||
| 855 | ## 2.7 Feature-Flagged Actions | ||
| 856 | |||
| 857 | All Phase 2 functionality is gated behind the `agent-v2` feature flag. | ||
| 858 | |||
| 859 | ### New Actions | ||
| 860 | |||
| 861 | ```rust | ||
| 862 | // crates/workspace/src/workspace.rs | ||
| 863 | |||
| 864 | actions!( | ||
| 865 | workspace, | ||
| 866 | [ | ||
| 867 | /// Switch to a specific thread's workspace state | ||
| 868 | SwitchToThread, | ||
| 869 | /// Open a thread (loads worktrees, creates WorkspaceState if needed) | ||
| 870 | OpenThread, | ||
| 871 | /// Close a thread's workspace state | ||
| 872 | CloseThread, | ||
| 873 | ] | ||
| 874 | ); | ||
| 875 | |||
| 876 | #[derive(Clone, Debug, Deserialize, PartialEq)] | ||
| 877 | pub struct SwitchToThread { | ||
| 878 | pub session_id: String, | ||
| 879 | } | ||
| 880 | |||
| 881 | impl_actions!(workspace, [SwitchToThread, OpenThread, CloseThread]); | ||
| 882 | ``` | ||
| 883 | |||
| 884 | ### Action Handlers | ||
| 885 | |||
| 886 | ```rust | ||
| 887 | impl Workspace { | ||
| 888 | pub fn register_thread_actions(cx: &mut App) { | ||
| 889 | if !cx.has_flag::<AgentV2FeatureFlag>() { | ||
| 890 | return; | ||
| 891 | } | ||
| 892 | |||
| 893 | cx.on_action(|workspace: &mut Workspace, action: &SwitchToThread, window, cx| { | ||
| 894 | let session_id = acp::SessionId::from(action.session_id.clone()); | ||
| 895 | let workspace_db_id = workspace.database_id().unwrap_or_default(); | ||
| 896 | let thread_worktrees_id = ThreadWorktreesId::for_thread(session_id, workspace_db_id); | ||
| 897 | workspace.switch_to_thread(thread_worktrees_id, window, cx); | ||
| 898 | }); | ||
| 899 | } | ||
| 900 | } | ||
| 901 | ``` | ||
| 902 | |||
| 903 | --- | ||
| 904 | |||
| 905 | ## Cross-Thread File References | ||
| 906 | |||
| 907 | **Resolution: Center panes are NOT scoped, only panels are** | ||
| 908 | |||
| 909 | When "Go to Definition" (or similar navigation) targets a file outside the current ThreadWorktrees: | ||
| 910 | |||
| 911 | - **If the worktree is loaded** (another thread is live) → **Just works**. The file opens in a center tab. ProjectPanel won't show it in its tree (filtered by ThreadWorktrees), but the editor works normally. | ||
| 912 | |||
| 913 | - **If the worktree is NOT loaded** → **File not available**. Show message: "Definition is in `/path` which is not currently loaded." | ||
| 914 | |||
| 915 | ### Architecture | ||
| 916 | |||
| 917 | ``` | ||
| 918 | ┌─────────────────────────────────────────────────────────┐ | ||
| 919 | │ Window │ | ||
| 920 | ├──────────┬──────────────────────────────────────────────┤ | ||
| 921 | │ Agents │ ┌─────────┬──────────────────┬────────┐ │ | ||
| 922 | │ Sidebar │ │ Left │ │ Right │ │ | ||
| 923 | │ │ │ Dock │ Center Panes │ Dock │ │ | ||
| 924 | │ │ │ │ │ │ │ | ||
| 925 | │ │ │ SCOPED │ NOT SCOPED │ SCOPED │ │ | ||
| 926 | │ │ │ (shows │ (can open any │ │ │ | ||
| 927 | │ │ │ only │ buffer from │ │ │ | ||
| 928 | │ │ │ Thread │ any loaded │ │ │ | ||
| 929 | │ │ │ A's │ worktree) │ │ │ | ||
| 930 | │ │ │ trees) │ │ │ │ | ||
| 931 | │ │ └─────────┴──────────────────┴────────┘ │ | ||
| 932 | └──────────┴──────────────────────────────────────────────┘ | ||
| 933 | ``` | ||
| 934 | |||
| 935 | This matches current Zed behavior - you can have open files that aren't in the project panel (via drag-drop, external open, etc.). | ||
| 936 | |||
| 937 | --- | ||
| 938 | |||
| 939 | ## Key Files to Modify | ||
| 940 | |||
| 941 | | File | Changes | | ||
| 942 | |------|---------| | ||
| 943 | | `crates/project/src/thread_worktrees.rs` | **NEW**: ThreadWorktrees struct, ThreadWorktreesId, event filtering | | ||
| 944 | | `crates/project/src/project.rs` | Add `thread_worktrees` HashMap, `active_thread_worktrees_id`, helper methods | | ||
| 945 | | `crates/workspace/src/workspace.rs` | Add `WorkspaceState`, `workspace_states` HashMap, switch_to_thread logic | | ||
| 946 | | `crates/git_ui/src/git_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 947 | | `crates/project_panel/src/project_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 948 | | `crates/outline_panel/src/outline_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 949 | | `crates/debugger_ui/src/debugger_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 950 | | `crates/terminal_view/src/terminal_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 951 | | `crates/agent_ui/src/agent_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 952 | |||
| 953 | --- | ||
| 954 | |||
| 955 | ## Implementation Order | ||
| 956 | |||
| 957 | ### Step 1: Core Infrastructure | ||
| 958 | 1. Create `crates/project/src/thread_worktrees.rs` with `ThreadWorktreesId` and `ThreadWorktrees` struct | ||
| 959 | 2. Add basic API methods (scoped worktree access, pass-throughs) | ||
| 960 | 3. Add `thread_worktrees` HashMap to `Project` | ||
| 961 | 4. Wire up module in `crates/project/src/project.rs` | ||
| 962 | |||
| 963 | ### Step 2: Event Filtering | ||
| 964 | 1. Implement `ThreadWorktreesEvent` enum | ||
| 965 | 2. Add subscriptions to git_store and worktree_store in `ThreadWorktrees::new` | ||
| 966 | 3. Implement `handle_git_store_event` and `handle_worktree_store_event` | ||
| 967 | |||
| 968 | ### Step 3: WorkspaceState | ||
| 969 | 1. Create `WorkspaceState` struct in workspace.rs | ||
| 970 | 2. Add `workspace_states` HashMap to `Workspace` | ||
| 971 | 3. Implement `switch_to_thread` method | ||
| 972 | 4. Add feature-flagged actions | ||
| 973 | |||
| 974 | ### Step 4: Panel Migration (one at a time) | ||
| 975 | 1. Start with `ProjectPanel` (simplest, most isolated) | ||
| 976 | 2. Then `GitPanel` (tests event filtering) | ||
| 977 | 3. Then remaining panels | ||
| 978 | |||
| 979 | ### Step 5: Integration Testing | ||
| 980 | 1. Test switching between threads | ||
| 981 | 2. Test panel state preservation | ||
| 982 | 3. Test cross-thread file navigation | ||
| 983 | |||
| 984 | --- | ||
| 985 | |||
| 986 | ## Testing Strategy | ||
| 987 | |||
| 988 | ### Unit Tests | ||
| 989 | |||
| 990 | ```rust | ||
| 991 | #[gpui::test] | ||
| 992 | async fn test_thread_worktrees_scopes_worktrees(cx: &mut TestAppContext) { | ||
| 993 | let fs = FakeFs::new(cx.background_executor.clone()); | ||
| 994 | fs.insert_tree("/project_a", json!({"src": {"main.rs": ""}})).await; | ||
| 995 | fs.insert_tree("/project_b", json!({"lib": {"mod.rs": ""}})).await; | ||
| 996 | |||
| 997 | let project = Project::test(fs.clone(), ["/project_a", "/project_b"], cx).await; | ||
| 998 | |||
| 999 | // Create ThreadWorktrees with only project_a | ||
| 1000 | let worktree_a_id = project.read_with(cx, |p, cx| { | ||
| 1001 | p.worktrees(cx).next().unwrap().read(cx).id() | ||
| 1002 | }); | ||
| 1003 | |||
| 1004 | let thread_worktrees = cx.new(|cx| { | ||
| 1005 | ThreadWorktrees::new( | ||
| 1006 | ThreadWorktreesId::default_for_workspace(WorkspaceId(1)), | ||
| 1007 | project.clone(), | ||
| 1008 | HashSet::from([worktree_a_id]), | ||
| 1009 | cx, | ||
| 1010 | ) | ||
| 1011 | }); | ||
| 1012 | |||
| 1013 | // Should only see project_a worktree | ||
| 1014 | let visible: Vec<_> = thread_worktrees.read_with(cx, |tw, cx| { | ||
| 1015 | tw.visible_worktrees(cx).collect() | ||
| 1016 | }); | ||
| 1017 | assert_eq!(visible.len(), 1); | ||
| 1018 | } | ||
| 1019 | ``` | ||
| 1020 | |||
| 1021 | ### Integration Tests | ||
| 1022 | |||
| 1023 | ```rust | ||
| 1024 | #[gpui::test] | ||
| 1025 | async fn test_switch_between_threads_preserves_state(cx: &mut TestAppContext) { | ||
| 1026 | // Setup workspace with two threads | ||
| 1027 | // Switch to thread A, make changes | ||
| 1028 | // Switch to thread B | ||
| 1029 | // Switch back to thread A | ||
| 1030 | // Verify state is preserved | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | #[gpui::test] | ||
| 1034 | async fn test_panel_receives_filtered_events(cx: &mut TestAppContext) { | ||
| 1035 | // Setup ThreadWorktrees with subset of worktrees | ||
| 1036 | // Trigger git event on worktree outside scope | ||
| 1037 | // Verify panel doesn't receive event | ||
| 1038 | // Trigger git event on worktree inside scope | ||
| 1039 | // Verify panel receives event | ||
| 1040 | } | ||
| 1041 | ``` | ||
| 1042 | |||
| 1043 | --- | ||
| 1044 | |||
| 1045 | ## Design Decisions Summary | ||
| 1046 | |||
| 1047 | 1. **Project remains singular** - One `Entity<Project>` per window holds all worktrees. ThreadWorktrees is a filtered lens, not a separate data store. | ||
| 1048 | |||
| 1049 | 2. **ThreadWorktrees as panel API surface** - Provides type safety, centralized event filtering, and clear API boundary. | ||
| 1050 | |||
| 1051 | 3. **Buffer store is shared** - Buffers represent files which are singular. Edits in shared worktrees are visible across all threads immediately. | ||
| 1052 | |||
| 1053 | 4. **Center panes are not scoped** - Only panels are filtered. Users can open any file from any loaded worktree in editor tabs. | ||
| 1054 | |||
| 1055 | 5. **Panel state preserved by design** - Each ThreadWorktrees has its own panel instances stored in WorkspaceState. Switching threads swaps which instances are displayed. | ||
| 1056 | |||
| 1057 | 6. **Reference counting handles cleanup** - No explicit cleanup logic needed. When WorkspaceState is removed, GPUI drops panel instances automatically. | ||
| 1058 | |||
| 1059 | --- | ||
| 1060 | |||
| 1061 | ## Open Questions for Phase 2 | ||
| 1062 | |||
| 1063 | 1. **Dock panel swapping mechanism** - How exactly do docks swap panel instances? May need new Dock API. | ||
| 1064 | |||
| 1065 | 2. **Panel initialization timing** - Should panels be lazily initialized on first access or eagerly on thread switch? | ||
| 1066 | |||
| 1067 | 3. **Serialization** - Should WorkspaceState be serialized per-thread for restore on restart? (Likely Phase 3) | ||
| @@ -1,408 +0,0 @@ | |||
| 1 | # Pluggable Shaders for GPUI | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | This document outlines a plan for adding pluggable shader support to GPUI, allowing users to define custom shaders, pipelines, and primitives that integrate with GPUI's rendering system. | ||
| 6 | |||
| 7 | ## Goals | ||
| 8 | |||
| 9 | - Allow users to define custom WGSL shaders for the Blade backend | ||
| 10 | - Allow users to register custom render pipelines with GPUI | ||
| 11 | - Allow users to define custom primitive types with `#[repr(C)]` structs for GPU data | ||
| 12 | - Integrate custom primitives into GPUI's batching and draw-order system | ||
| 13 | - Provide a clean, type-safe API for painting custom primitives during element rendering | ||
| 14 | |||
| 15 | ## Non-Goals | ||
| 16 | |||
| 17 | - Supporting Metal or DirectX shader backends initially (focus on Blade/WGSL) | ||
| 18 | - Automatic shader hot-reloading | ||
| 19 | - Providing a shader authoring GUI | ||
| 20 | - Multi-pass rendering for custom primitives (can be added later) | ||
| 21 | |||
| 22 | ## Current State | ||
| 23 | |||
| 24 | ### Rendering Architecture | ||
| 25 | |||
| 26 | GPUI uses a multi-backend rendering system: | ||
| 27 | - **Blade** (Linux, FreeBSD, macOS with feature flag) - uses WGSL shaders | ||
| 28 | - **Metal** (macOS default) - uses Metal shaders | ||
| 29 | - **DirectX** (Windows) - uses HLSL shaders | ||
| 30 | |||
| 31 | ### Key Components | ||
| 32 | |||
| 33 | 1. **Scene** (`crates/gpui/src/scene.rs:24-36`): Holds all primitives to render | ||
| 34 | - Contains typed vectors for each primitive type (quads, shadows, paths, etc.) | ||
| 35 | - Manages draw ordering via `DrawOrder` (u32) | ||
| 36 | - Provides `batches()` iterator for efficient GPU rendering | ||
| 37 | |||
| 38 | 2. **Primitive Types** (`scene.rs:211-220`): 8 built-in types | ||
| 39 | - Shadow, Quad, Path, Underline | ||
| 40 | - MonochromeSprite, SubpixelSprite, PolychromeSprite | ||
| 41 | - Surface (video surfaces, macOS only) | ||
| 42 | |||
| 43 | 3. **BladeRenderer** (`blade_renderer.rs:369-386`): | ||
| 44 | - Owns pipelines, instance buffers, atlas, textures | ||
| 45 | - `draw()` method iterates batches and dispatches to appropriate pipelines | ||
| 46 | |||
| 47 | 4. **BladePipelines** (`blade_renderer.rs:140-150`): | ||
| 48 | - Struct holding all render pipelines | ||
| 49 | - Created at window initialization with shader source | ||
| 50 | |||
| 51 | 5. **Shader Data** (`blade_renderer.rs:56-123`): | ||
| 52 | - Uses `#[derive(blade_macros::ShaderData)]` for GPU bindings | ||
| 53 | - GlobalParams uniform, storage buffers for instance data | ||
| 54 | |||
| 55 | ### Data Flow | ||
| 56 | |||
| 57 | ``` | ||
| 58 | Window::paint_*() | ||
| 59 | → Scene::insert_primitive() | ||
| 60 | → Scene::finish() sorts by order | ||
| 61 | → BladeRenderer::draw() | ||
| 62 | → scene.batches() iterator | ||
| 63 | → For each batch: bind pipeline, upload data, draw | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ## Proposed Design | ||
| 67 | |||
| 68 | ### Architecture | ||
| 69 | |||
| 70 | The design adds an extensible primitive system alongside the existing built-in primitives: | ||
| 71 | |||
| 72 | ``` | ||
| 73 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 74 | │ User Code │ | ||
| 75 | │ - Define CustomPrimitive struct (#[repr(C)]) │ | ||
| 76 | │ - Provide WGSL shader source │ | ||
| 77 | │ - Register with GPUI via CustomShaderDescriptor │ | ||
| 78 | └─────────────────────────────────────────────────────────────┘ | ||
| 79 | │ | ||
| 80 | ▼ | ||
| 81 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 82 | │ CustomShaderRegistry │ | ||
| 83 | │ - Stores registered shader descriptors │ | ||
| 84 | │ - Creates pipelines on renderer initialization │ | ||
| 85 | │ - Maps CustomShaderId → pipeline + metadata │ | ||
| 86 | └─────────────────────────────────────────────────────────────┘ | ||
| 87 | │ | ||
| 88 | ▼ | ||
| 89 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 90 | │ Scene (Extended) │ | ||
| 91 | │ - Existing primitive vectors │ | ||
| 92 | │ - New: custom_primitives: HashMap<CustomShaderId, Vec<u8>> │ | ||
| 93 | │ - BatchIterator yields CustomBatch alongside built-ins │ | ||
| 94 | └─────────────────────────────────────────────────────────────┘ | ||
| 95 | │ | ||
| 96 | ▼ | ||
| 97 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 98 | │ BladeRenderer (Extended) │ | ||
| 99 | │ - Existing pipelines │ | ||
| 100 | │ - New: custom_pipelines: HashMap<CustomShaderId, Pipeline> │ | ||
| 101 | │ - draw() handles PrimitiveBatch::Custom │ | ||
| 102 | └─────────────────────────────────────────────────────────────┘ | ||
| 103 | ``` | ||
| 104 | |||
| 105 | ### API Surface | ||
| 106 | |||
| 107 | #### 1. Custom Shader Descriptor | ||
| 108 | |||
| 109 | ```rust | ||
| 110 | /// Describes a custom shader and its pipeline configuration. | ||
| 111 | pub struct CustomShaderDescriptor { | ||
| 112 | /// Unique identifier for this shader | ||
| 113 | pub name: &'static str, | ||
| 114 | |||
| 115 | /// WGSL shader source code | ||
| 116 | pub shader_source: &'static str, | ||
| 117 | |||
| 118 | /// Vertex shader entry point name | ||
| 119 | pub vertex_entry: &'static str, | ||
| 120 | |||
| 121 | /// Fragment shader entry point name | ||
| 122 | pub fragment_entry: &'static str, | ||
| 123 | |||
| 124 | /// Size of each instance in bytes (must match #[repr(C)] struct) | ||
| 125 | pub instance_size: usize, | ||
| 126 | |||
| 127 | /// Blend mode for this shader | ||
| 128 | pub blend_mode: CustomBlendMode, | ||
| 129 | |||
| 130 | /// Primitive topology (typically TriangleStrip for instanced quads) | ||
| 131 | pub topology: PrimitiveTopology, | ||
| 132 | } | ||
| 133 | |||
| 134 | pub enum CustomBlendMode { | ||
| 135 | /// Standard alpha blending | ||
| 136 | Alpha, | ||
| 137 | /// Premultiplied alpha | ||
| 138 | PremultipliedAlpha, | ||
| 139 | /// Additive blending | ||
| 140 | Additive, | ||
| 141 | /// No blending (opaque) | ||
| 142 | Opaque, | ||
| 143 | } | ||
| 144 | |||
| 145 | pub enum PrimitiveTopology { | ||
| 146 | TriangleStrip, | ||
| 147 | TriangleList, | ||
| 148 | } | ||
| 149 | ``` | ||
| 150 | |||
| 151 | #### 2. Custom Shader Handle | ||
| 152 | |||
| 153 | ```rust | ||
| 154 | /// Opaque handle to a registered custom shader. | ||
| 155 | #[derive(Clone, Copy, PartialEq, Eq, Hash)] | ||
| 156 | pub struct CustomShaderId(u32); | ||
| 157 | ``` | ||
| 158 | |||
| 159 | #### 3. Custom Primitive Trait | ||
| 160 | |||
| 161 | ```rust | ||
| 162 | /// Trait for types that can be rendered as custom primitives. | ||
| 163 | /// | ||
| 164 | /// # Safety | ||
| 165 | /// The implementing type must be `#[repr(C)]` and match the expected | ||
| 166 | /// layout in the WGSL shader. | ||
| 167 | pub unsafe trait CustomPrimitive: Pod + Zeroable + Clone { | ||
| 168 | /// Returns the bounds of this primitive for clipping/culling. | ||
| 169 | fn bounds(&self) -> Bounds<ScaledPixels>; | ||
| 170 | |||
| 171 | /// Returns the content mask for clipping. | ||
| 172 | fn content_mask(&self) -> ContentMask<ScaledPixels>; | ||
| 173 | |||
| 174 | /// Returns the draw order for sorting. | ||
| 175 | fn order(&self) -> DrawOrder; | ||
| 176 | |||
| 177 | /// Sets the draw order. | ||
| 178 | fn set_order(&mut self, order: DrawOrder); | ||
| 179 | } | ||
| 180 | ``` | ||
| 181 | |||
| 182 | #### 4. Registration API (on App) | ||
| 183 | |||
| 184 | ```rust | ||
| 185 | impl App { | ||
| 186 | /// Register a custom shader with GPUI. | ||
| 187 | /// | ||
| 188 | /// This should be called during app initialization before any windows | ||
| 189 | /// are created. The shader will be compiled when windows are opened. | ||
| 190 | pub fn register_custom_shader( | ||
| 191 | &mut self, | ||
| 192 | descriptor: CustomShaderDescriptor, | ||
| 193 | ) -> CustomShaderId; | ||
| 194 | } | ||
| 195 | ``` | ||
| 196 | |||
| 197 | #### 5. Painting API (on Window) | ||
| 198 | |||
| 199 | ```rust | ||
| 200 | impl Window { | ||
| 201 | /// Paint a custom primitive into the scene. | ||
| 202 | /// | ||
| 203 | /// The primitive type must match the shader it was registered with. | ||
| 204 | /// This method should only be called during the paint phase. | ||
| 205 | pub fn paint_custom<P: CustomPrimitive>( | ||
| 206 | &mut self, | ||
| 207 | shader_id: CustomShaderId, | ||
| 208 | primitive: P, | ||
| 209 | ); | ||
| 210 | |||
| 211 | /// Paint multiple custom primitives of the same type. | ||
| 212 | /// | ||
| 213 | /// More efficient than calling paint_custom repeatedly. | ||
| 214 | pub fn paint_custom_batch<P: CustomPrimitive>( | ||
| 215 | &mut self, | ||
| 216 | shader_id: CustomShaderId, | ||
| 217 | primitives: &[P], | ||
| 218 | ); | ||
| 219 | } | ||
| 220 | ``` | ||
| 221 | |||
| 222 | ### Example Usage | ||
| 223 | |||
| 224 | ```rust | ||
| 225 | // 1. Define the primitive struct (must match shader layout) | ||
| 226 | #[repr(C)] | ||
| 227 | #[derive(Clone, Copy, Pod, Zeroable)] | ||
| 228 | struct GlowQuad { | ||
| 229 | order: u32, | ||
| 230 | _pad: u32, | ||
| 231 | bounds: Bounds<ScaledPixels>, | ||
| 232 | content_mask: ContentMask<ScaledPixels>, | ||
| 233 | color: Hsla, | ||
| 234 | glow_radius: f32, | ||
| 235 | glow_intensity: f32, | ||
| 236 | } | ||
| 237 | |||
| 238 | unsafe impl CustomPrimitive for GlowQuad { | ||
| 239 | fn bounds(&self) -> Bounds<ScaledPixels> { self.bounds } | ||
| 240 | fn content_mask(&self) -> ContentMask<ScaledPixels> { self.content_mask } | ||
| 241 | fn order(&self) -> DrawOrder { self.order } | ||
| 242 | fn set_order(&mut self, order: DrawOrder) { self.order = order; } | ||
| 243 | } | ||
| 244 | |||
| 245 | // 2. Define the shader | ||
| 246 | const GLOW_SHADER: &str = r#" | ||
| 247 | struct GlowQuad { | ||
| 248 | order: u32, | ||
| 249 | pad: u32, | ||
| 250 | bounds: Bounds, | ||
| 251 | content_mask: Bounds, | ||
| 252 | color: Hsla, | ||
| 253 | glow_radius: f32, | ||
| 254 | glow_intensity: f32, | ||
| 255 | } | ||
| 256 | |||
| 257 | var<storage, read> b_glow_quads: array<GlowQuad>; | ||
| 258 | |||
| 259 | struct GlowVarying { | ||
| 260 | @builtin(position) position: vec4<f32>, | ||
| 261 | @location(0) @interpolate(flat) color: vec4<f32>, | ||
| 262 | // ... more fields | ||
| 263 | } | ||
| 264 | |||
| 265 | @vertex | ||
| 266 | fn vs_glow(@builtin(vertex_index) vertex_id: u32, | ||
| 267 | @builtin(instance_index) instance_id: u32) -> GlowVarying { | ||
| 268 | let unit_vertex = vec2<f32>(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); | ||
| 269 | let quad = b_glow_quads[instance_id]; | ||
| 270 | // ... transform and output | ||
| 271 | } | ||
| 272 | |||
| 273 | @fragment | ||
| 274 | fn fs_glow(input: GlowVarying) -> @location(0) vec4<f32> { | ||
| 275 | // ... glow effect calculation | ||
| 276 | } | ||
| 277 | "#; | ||
| 278 | |||
| 279 | // 3. Register at app startup | ||
| 280 | fn main() { | ||
| 281 | App::new().run(|cx| { | ||
| 282 | let glow_shader = cx.register_custom_shader(CustomShaderDescriptor { | ||
| 283 | name: "glow_quad", | ||
| 284 | shader_source: GLOW_SHADER, | ||
| 285 | vertex_entry: "vs_glow", | ||
| 286 | fragment_entry: "fs_glow", | ||
| 287 | instance_size: std::mem::size_of::<GlowQuad>(), | ||
| 288 | blend_mode: CustomBlendMode::PremultipliedAlpha, | ||
| 289 | topology: PrimitiveTopology::TriangleStrip, | ||
| 290 | }); | ||
| 291 | |||
| 292 | // Store shader_id somewhere accessible (e.g., Global) | ||
| 293 | cx.set_global(GlowShader(glow_shader)); | ||
| 294 | |||
| 295 | // ... rest of app setup | ||
| 296 | }); | ||
| 297 | } | ||
| 298 | |||
| 299 | // 4. Use during element painting | ||
| 300 | impl Render for MyElement { | ||
| 301 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | ||
| 302 | div() | ||
| 303 | .child("Hello") | ||
| 304 | .on_paint(cx.listener(|this, _bounds, window, cx| { | ||
| 305 | let shader_id = cx.global::<GlowShader>().0; | ||
| 306 | window.paint_custom(shader_id, GlowQuad { | ||
| 307 | order: 0, | ||
| 308 | _pad: 0, | ||
| 309 | bounds: /* ... */, | ||
| 310 | content_mask: window.content_mask().scale(window.scale_factor()), | ||
| 311 | color: hsla(0.5, 1.0, 0.5, 1.0), | ||
| 312 | glow_radius: 10.0, | ||
| 313 | glow_intensity: 0.8, | ||
| 314 | }); | ||
| 315 | })) | ||
| 316 | } | ||
| 317 | } | ||
| 318 | ``` | ||
| 319 | |||
| 320 | ### Implementation Steps | ||
| 321 | |||
| 322 | 1. **[DONE] Add CustomShaderDescriptor and CustomShaderId types** (`crates/gpui/src/custom_shader.rs`) | ||
| 323 | - Define the descriptor struct | ||
| 324 | - Define the ID type | ||
| 325 | - Define CustomPrimitive trait | ||
| 326 | - Added `GPUI_SHADER_HEADER` constant with common shader utilities | ||
| 327 | |||
| 328 | 2. **[DONE] Add CustomShaderRegistry** (`crates/gpui/src/custom_shader.rs`) | ||
| 329 | - Store registered descriptors | ||
| 330 | - Provide lookup by ID | ||
| 331 | - Track which shaders need pipelines created | ||
| 332 | |||
| 333 | 3. **[DONE] Extend App with registration API** | ||
| 334 | - Add `register_custom_shader()` method | ||
| 335 | - Store registry in App state (`custom_shader_registry` field) | ||
| 336 | |||
| 337 | 4. **[DONE] Extend Scene with custom primitive storage** (`crates/gpui/src/scene.rs`) | ||
| 338 | - Add `custom_primitives: HashMap<CustomShaderId, CustomPrimitiveBuffer>` | ||
| 339 | - Add `insert_custom_primitive()` method | ||
| 340 | - Add `custom_batches()` method to yield custom batches separately | ||
| 341 | |||
| 342 | 5. **[DONE] Add PrimitiveBatch::Custom variant** (`crates/gpui/src/scene.rs`) | ||
| 343 | ```rust | ||
| 344 | Custom { | ||
| 345 | shader_id: CustomShaderId, | ||
| 346 | data: &'a [u8], | ||
| 347 | instance_count: u32, | ||
| 348 | vertices_per_instance: u32, | ||
| 349 | } | ||
| 350 | ``` | ||
| 351 | |||
| 352 | 6. **[DONE] Extend BladeRenderer** (`crates/gpui/src/platform/blade/blade_renderer.rs`) | ||
| 353 | - Add `custom_pipelines: HashMap<CustomShaderId, CustomPipelineEntry>` | ||
| 354 | - Add `register_custom_shader()` method to create pipelines | ||
| 355 | - Handle `PrimitiveBatch::Custom` in `draw()` via `custom_batches()` iterator | ||
| 356 | - Add `ShaderCustomData` struct for GPU bindings | ||
| 357 | |||
| 358 | 7. **[DONE] Add Window painting API** (`crates/gpui/src/window.rs`) | ||
| 359 | - Add `paint_custom()` method | ||
| 360 | |||
| 361 | 8. **[DONE] Create helper shader functions module** | ||
| 362 | - Export common WGSL functions via `GPUI_SHADER_HEADER` constant | ||
| 363 | - Includes: GlobalParams, Bounds, Hsla, to_device_position, hsla_to_rgba, blend_color, etc. | ||
| 364 | |||
| 365 | 9. **[TODO] Add tests and examples** | ||
| 366 | - Unit tests for registration and painting | ||
| 367 | - Example custom shader (e.g., glow effect, noise pattern) | ||
| 368 | |||
| 369 | 10. **[TODO] Wire up shader registration from App to Renderer** | ||
| 370 | - Currently renderer has `register_custom_shader()` but needs to be called | ||
| 371 | - Need to plumb shader registry to window creation | ||
| 372 | |||
| 373 | ## Open Questions | ||
| 374 | |||
| 375 | 1. **Global vs Per-Window Shader Registration?** | ||
| 376 | - Current design: Global registration, per-window pipeline creation | ||
| 377 | - Alternative: Per-window registration (more flexible, more complex) | ||
| 378 | - **Decision**: Global registration is simpler and matches most use cases | ||
| 379 | |||
| 380 | 2. **How to handle shader compilation errors?** | ||
| 381 | - Option A: Panic on invalid shader (current built-in behavior) | ||
| 382 | - Option B: Return Result from registration | ||
| 383 | - Option C: Log error and skip rendering | ||
| 384 | - **Recommendation**: Return Result, let users handle errors | ||
| 385 | |||
| 386 | 3. **Should custom primitives participate in layer ordering?** | ||
| 387 | - Built-in primitives use `layer_stack` for z-ordering | ||
| 388 | - Custom primitives should integrate with this | ||
| 389 | - **Decision**: Yes, use the same DrawOrder system | ||
| 390 | |||
| 391 | 4. **Texture access for custom shaders?** | ||
| 392 | - Some custom effects need textures (e.g., noise, patterns) | ||
| 393 | - Could expose atlas access or allow custom texture uploads | ||
| 394 | - **Recommendation**: Add optional texture binding support in v2 | ||
| 395 | |||
| 396 | 5. **Metal/DirectX support?** | ||
| 397 | - Initial implementation is Blade-only | ||
| 398 | - Could add abstractions for cross-platform shaders later | ||
| 399 | - **Recommendation**: Start with Blade, add others based on demand | ||
| 400 | |||
| 401 | ## References | ||
| 402 | |||
| 403 | - `crates/gpui/src/scene.rs` - Scene and primitive definitions | ||
| 404 | - `crates/gpui/src/platform/blade/blade_renderer.rs` - Blade rendering implementation | ||
| 405 | - `crates/gpui/src/platform/blade/shaders.wgsl` - Built-in WGSL shaders | ||
| 406 | - `crates/gpui/src/window.rs` - Window painting API | ||
| 407 | - [blade_graphics crate](https://docs.rs/blade-graphics) - GPU abstraction layer | ||
| 408 | - [WGSL specification](https://www.w3.org/TR/WGSL/) - WebGPU Shading Language | ||
| @@ -1,921 +0,0 @@ | |||
| 1 | # MultiWorkspace Agent V2 Refactor | ||
| 2 | |||
| 3 | |||
| 4 | |||
| 5 | |||
| 6 | |||
| 7 | |||
| 8 | |||
| 9 | This is a multi-phase project to enable Agent V2's vision: a threads sidebar that can switch between projects within a single window, with global thread management and folder ownership. | ||
| 10 | |||
| 11 | --- | ||
| 12 | |||
| 13 | ## Phase 1: GlobalThreadStore Infrastructure | ||
| 14 | |||
| 15 | Adapt the existing `ThreadStore` to be global and add the data structures for project path associations and folder ownership tracking. **Note:** Association UI is deferred to Phase 3 - all threads will have empty paths initially. | ||
| 16 | |||
| 17 | ### 1.1 Make ThreadStore Global | ||
| 18 | |||
| 19 | Add Global wrapper pattern (similar to `HistoryManager`): | ||
| 20 | |||
| 21 | ```rust | ||
| 22 | // In crates/agent/src/thread_store.rs | ||
| 23 | struct GlobalThreadStore(Entity<ThreadStore>); | ||
| 24 | impl Global for GlobalThreadStore {} | ||
| 25 | |||
| 26 | impl ThreadStore { | ||
| 27 | pub fn init(cx: &mut App) { ... } | ||
| 28 | pub fn global(cx: &App) -> Entity<Self> { ... } | ||
| 29 | } | ||
| 30 | ``` | ||
| 31 | |||
| 32 | Initialize in [`crates/zed/src/main.rs`](crates/zed/src/main.rs) near other global inits. | ||
| 33 | |||
| 34 | ### 1.2 Add Project Paths to Thread Storage | ||
| 35 | |||
| 36 | **Database migration** - Add `project_paths` column to threads table in [`crates/agent/src/db.rs`](crates/agent/src/db.rs): | ||
| 37 | |||
| 38 | ```rust | ||
| 39 | // DbThreadMetadata - add project_paths for quick access | ||
| 40 | pub struct DbThreadMetadata { | ||
| 41 | pub id: acp::SessionId, | ||
| 42 | pub title: SharedString, | ||
| 43 | pub updated_at: DateTime<Utc>, | ||
| 44 | pub project_paths: Vec<PathBuf>, // NEW | ||
| 45 | } | ||
| 46 | |||
| 47 | // DbThread - add with serde default for backwards compat | ||
| 48 | pub struct DbThread { | ||
| 49 | // ... existing fields ... | ||
| 50 | #[serde(default)] | ||
| 51 | pub project_paths: Vec<PathBuf>, // NEW | ||
| 52 | } | ||
| 53 | ``` | ||
| 54 | |||
| 55 | ### 1.3 Folder Ownership Tracking | ||
| 56 | |||
| 57 | Add ownership tracking to `ThreadStore`: | ||
| 58 | |||
| 59 | ```rust | ||
| 60 | pub struct ThreadStore { | ||
| 61 | threads: Vec<DbThreadMetadata>, | ||
| 62 | // Tracks which folders are owned by which window | ||
| 63 | folder_owners: HashMap<PathBuf, WeakWindowHandle<Workspace>>, | ||
| 64 | // Tracks which thread is actively open in which window | ||
| 65 | active_threads: HashMap<acp::SessionId, WeakWindowHandle<Workspace>>, | ||
| 66 | } | ||
| 67 | ``` | ||
| 68 | |||
| 69 | Key methods: | ||
| 70 | |||
| 71 | - `claim_thread(session_id, window, project_paths)` - Claims ownership of thread's folders | ||
| 72 | - `release_thread(session_id)` - Releases folder ownership | ||
| 73 | - `can_open_thread(session_id, window)` - Checks if any folders overlap with another window | ||
| 74 | - `thread_available_in_window(session_id, window)` - Returns availability status for UI | ||
| 75 | |||
| 76 | ### 1.4 Update Consumers | ||
| 77 | |||
| 78 | Update [`crates/agent_ui_v2/src/agents_panel.rs`](crates/agent_ui_v2/src/agents_panel.rs): | ||
| 79 | |||
| 80 | - Replace `cx.new(|cx| ThreadStore::new(cx))` with `ThreadStore::global(cx)` | ||
| 81 | |||
| 82 | **Note:** Folder overlap UI indicators are deferred to Phase 3. Since all threads have empty paths initially, there will be no conflicts to show. | ||
| 83 | |||
| 84 | --- | ||
| 85 | |||
| 86 | ## Phase 2: MultiWorkspace | ||
| 87 | |||
| 88 | Enable the workspace to swap between different ThreadWorktrees contexts while keeping state in memory. **Key insight:** Project stays singular per window; we swap which ThreadWorktrees group is active. | ||
| 89 | |||
| 90 | ### 2.1 New Concept: ThreadWorktrees | ||
| 91 | |||
| 92 | Add to [`crates/project/src/project.rs`](crates/project/src/project.rs): | ||
| 93 | |||
| 94 | ```rust | ||
| 95 | /// Identifies a ThreadWorktrees group - glues thread identity to workspace identity | ||
| 96 | #[derive(Clone, Hash, Eq, PartialEq)] | ||
| 97 | pub struct ThreadWorktreesId { | ||
| 98 | pub session_id: Option<acp::SessionId>, // None for "default" (no thread) | ||
| 99 | pub workspace_db_id: WorkspaceId, | ||
| 100 | } | ||
| 101 | |||
| 102 | /// Groups worktrees that belong to a specific thread | ||
| 103 | pub struct ThreadWorktrees { | ||
| 104 | pub id: ThreadWorktreesId, | ||
| 105 | pub worktree_ids: Vec<WorktreeId>, | ||
| 106 | pub folder_paths: Vec<PathBuf>, // Absolute paths for the folders | ||
| 107 | } | ||
| 108 | |||
| 109 | // Project additions | ||
| 110 | pub struct Project { | ||
| 111 | // Existing: all worktrees for all live threads in this window | ||
| 112 | worktree_store: WorktreeStore, | ||
| 113 | |||
| 114 | // NEW: Groups worktrees by thread | ||
| 115 | thread_worktrees: HashMap<ThreadWorktreesId, ThreadWorktrees>, | ||
| 116 | |||
| 117 | // NEW: Currently active thread context | ||
| 118 | active_thread_worktrees: ThreadWorktreesId, | ||
| 119 | // ... | ||
| 120 | } | ||
| 121 | ``` | ||
| 122 | |||
| 123 | **Worktree lifecycle:** | ||
| 124 | |||
| 125 | - Worktrees are automatically created/loaded when opening a thread | ||
| 126 | - Live threads (running in background) keep their worktrees loaded | ||
| 127 | - Worktrees shared between threads (e.g., /cloud in threads A and B) exist once in Project but are referenced by multiple ThreadWorktrees | ||
| 128 | |||
| 129 | ### 2.2 ThreadWorktrees as Panel API Surface | ||
| 130 | |||
| 131 | Panels currently hold `Entity<Project>` and call methods like `project.read(cx).git_store()`. In multi-workspace, panels need **scoped access** - GitPanel for ThreadWorktrees A shouldn't see repos from ThreadWorktrees B. | ||
| 132 | |||
| 133 | **ThreadWorktrees becomes the primary API surface for panels**, acting as a filtered lens into Project: | ||
| 134 | |||
| 135 | ```rust | ||
| 136 | pub struct ThreadWorktrees { | ||
| 137 | id: ThreadWorktreesId, | ||
| 138 | project: Entity<Project>, | ||
| 139 | worktree_ids: HashSet<WorktreeId>, | ||
| 140 | } | ||
| 141 | |||
| 142 | impl ThreadWorktrees { | ||
| 143 | // === Scoped worktree access === | ||
| 144 | pub fn visible_worktrees<'a>(&self, cx: &'a App) -> impl Iterator<Item = Entity<Worktree>> + 'a { | ||
| 145 | let dominated_ids = self.worktree_ids.clone(); | ||
| 146 | self.project.read(cx) | ||
| 147 | .visible_worktrees(cx) | ||
| 148 | .filter(move |wt| dominated_ids.contains(&wt.read(cx).id())) | ||
| 149 | } | ||
| 150 | |||
| 151 | pub fn worktree_for_id(&self, id: WorktreeId, cx: &App) -> Option<Entity<Worktree>> { | ||
| 152 | if self.worktree_ids.contains(&id) { | ||
| 153 | self.project.read(cx).worktree_for_id(id, cx) | ||
| 154 | } else { | ||
| 155 | None | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | // === Scoped git access === | ||
| 160 | pub fn active_repository(&self, cx: &App) -> Option<Entity<Repository>> { | ||
| 161 | let repo = self.project.read(cx).active_repository(cx)?; | ||
| 162 | let worktree_id = repo.read(cx).worktree_id(); | ||
| 163 | self.worktree_ids.contains(&worktree_id).then_some(repo) | ||
| 164 | } | ||
| 165 | |||
| 166 | pub fn git_store(&self, cx: &App) -> Entity<GitStore> { | ||
| 167 | // Returns the shared git_store - events are filtered separately | ||
| 168 | self.project.read(cx).git_store().clone() | ||
| 169 | } | ||
| 170 | |||
| 171 | // === Pass-through for global state === | ||
| 172 | pub fn is_via_collab(&self, cx: &App) -> bool { | ||
| 173 | self.project.read(cx).is_via_collab() | ||
| 174 | } | ||
| 175 | |||
| 176 | pub fn is_read_only(&self, cx: &App) -> bool { | ||
| 177 | self.project.read(cx).is_read_only(cx) | ||
| 178 | } | ||
| 179 | |||
| 180 | pub fn languages(&self, cx: &App) -> Arc<LanguageRegistry> { | ||
| 181 | self.project.read(cx).languages().clone() | ||
| 182 | } | ||
| 183 | |||
| 184 | pub fn path_style(&self, cx: &App) -> PathStyle { | ||
| 185 | self.project.read(cx).path_style(cx) | ||
| 186 | } | ||
| 187 | |||
| 188 | // === Scoped file operations === | ||
| 189 | pub fn open_buffer(&self, path: ProjectPath, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> { | ||
| 190 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 191 | return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | ||
| 192 | } | ||
| 193 | self.project.update(cx, |project, cx| project.open_buffer(path, cx)) | ||
| 194 | } | ||
| 195 | |||
| 196 | pub fn delete_file(&self, path: ProjectPath, trash: bool, cx: &mut Context<Self>) -> Option<Task<Result<()>>> { | ||
| 197 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 198 | return None; | ||
| 199 | } | ||
| 200 | self.project.update(cx, |project, cx| project.delete_file(path, trash, cx)) | ||
| 201 | } | ||
| 202 | } | ||
| 203 | ``` | ||
| 204 | |||
| 205 | **Project API usage categories:** | ||
| 206 | |||
| 207 | | Category | Examples | ThreadWorktrees behavior | | ||
| 208 | |----------|----------|-------------------------| | ||
| 209 | | Worktree-scoped | `visible_worktrees()`, `worktree_for_id()` | Filtered to owned worktrees | | ||
| 210 | | Git-scoped | `active_repository()`, `git_init()` | Filtered by worktree ownership | | ||
| 211 | | File operations | `open_buffer()`, `delete_file()` | Validated against scope | | ||
| 212 | | Global/shared | `is_via_collab()`, `languages()`, `path_style()` | Pass-through to Project | | ||
| 213 | |||
| 214 | **Event filtering via ThreadWorktrees:** | ||
| 215 | |||
| 216 | ThreadWorktrees re-emits filtered events so panels don't need to filter themselves: | ||
| 217 | |||
| 218 | ```rust | ||
| 219 | pub enum ThreadWorktreesEvent { | ||
| 220 | ActiveRepositoryChanged(Option<Entity<Repository>>), | ||
| 221 | RepositoryUpdated(RepositoryId), | ||
| 222 | WorktreeAdded(WorktreeId), | ||
| 223 | WorktreeRemoved(WorktreeId), | ||
| 224 | } | ||
| 225 | |||
| 226 | impl ThreadWorktrees { | ||
| 227 | fn new( | ||
| 228 | id: ThreadWorktreesId, | ||
| 229 | project: Entity<Project>, | ||
| 230 | worktree_ids: HashSet<WorktreeId>, | ||
| 231 | cx: &mut Context<Self>, | ||
| 232 | ) -> Self { | ||
| 233 | let git_store = project.read(cx).git_store().clone(); | ||
| 234 | |||
| 235 | // Subscribe to git_store and filter events | ||
| 236 | cx.subscribe(&git_store, |this, _store, event, cx| { | ||
| 237 | match event { | ||
| 238 | GitStoreEvent::ActiveRepositoryChanged(repo) => { | ||
| 239 | // Only emit if repo is in our worktrees | ||
| 240 | let dominated_repo = repo.as_ref().and_then(|r| { | ||
| 241 | this.worktree_ids.contains(&r.read(cx).worktree_id()) | ||
| 242 | .then(|| r.clone()) | ||
| 243 | }); | ||
| 244 | cx.emit(ThreadWorktreesEvent::ActiveRepositoryChanged(dominated_repo)); | ||
| 245 | } | ||
| 246 | GitStoreEvent::RepositoryUpdated(repo_id, _, _) => { | ||
| 247 | // Check if this repo belongs to our worktrees | ||
| 248 | if this.repo_in_scope(*repo_id, cx) { | ||
| 249 | cx.emit(ThreadWorktreesEvent::RepositoryUpdated(*repo_id)); | ||
| 250 | } | ||
| 251 | } | ||
| 252 | // ... filter other events similarly | ||
| 253 | } | ||
| 254 | }).detach(); | ||
| 255 | |||
| 256 | Self { id, project, worktree_ids } | ||
| 257 | } | ||
| 258 | } | ||
| 259 | ``` | ||
| 260 | |||
| 261 | **Panel migration pattern:** | ||
| 262 | |||
| 263 | ```rust | ||
| 264 | // Before: Panel holds Project | ||
| 265 | pub struct GitPanel { | ||
| 266 | project: Entity<Project>, | ||
| 267 | // ... | ||
| 268 | } | ||
| 269 | |||
| 270 | // After: Panel holds ThreadWorktrees | ||
| 271 | pub struct GitPanel { | ||
| 272 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 273 | // ... | ||
| 274 | } | ||
| 275 | |||
| 276 | // Before: Direct project access | ||
| 277 | let repos = self.project.read(cx).visible_worktrees(cx); | ||
| 278 | |||
| 279 | // After: Scoped access via ThreadWorktrees | ||
| 280 | let repos = self.thread_worktrees.read(cx).visible_worktrees(cx); | ||
| 281 | |||
| 282 | // Before: Subscribe to git_store directly | ||
| 283 | cx.subscribe(&git_store, |this, _, event, cx| { ... }); | ||
| 284 | |||
| 285 | // After: Subscribe to ThreadWorktrees events (pre-filtered) | ||
| 286 | cx.subscribe(&self.thread_worktrees, |this, _, event, cx| { | ||
| 287 | match event { | ||
| 288 | ThreadWorktreesEvent::ActiveRepositoryChanged(repo) => { | ||
| 289 | this.active_repository = repo.clone(); | ||
| 290 | this.schedule_update(window, cx); | ||
| 291 | } | ||
| 292 | // ... | ||
| 293 | } | ||
| 294 | }); | ||
| 295 | ``` | ||
| 296 | |||
| 297 | **Benefits of this approach:** | ||
| 298 | |||
| 299 | 1. **Type safety** - Panels can't accidentally access wrong scope; they only have `ThreadWorktrees` | ||
| 300 | 2. **Centralized filtering** - Event filtering happens once in `ThreadWorktrees`, not in every panel | ||
| 301 | 3. **Clear API boundary** - Easy to see which operations are scoped vs global | ||
| 302 | 4. **Testability** - Can create `ThreadWorktrees` with specific worktree sets for testing | ||
| 303 | |||
| 304 | ### 2.3 WorkspaceState per ThreadWorktrees | ||
| 305 | |||
| 306 | Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs): | ||
| 307 | |||
| 308 | ```rust | ||
| 309 | /// State for a specific ThreadWorktrees context | ||
| 310 | struct WorkspaceState { | ||
| 311 | thread_worktrees_id: ThreadWorktreesId, | ||
| 312 | center: PaneGroup, | ||
| 313 | panes: Vec<Entity<Pane>>, | ||
| 314 | panes_by_item: HashMap<EntityId, WeakEntity<Pane>>, | ||
| 315 | active_pane: Entity<Pane>, | ||
| 316 | // Panel instances - each ThreadWorktrees gets its own panel entities | ||
| 317 | panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | ||
| 318 | } | ||
| 319 | |||
| 320 | pub struct Workspace { | ||
| 321 | project: Entity<Project>, // Still singular | ||
| 322 | |||
| 323 | // NEW: State per ThreadWorktrees | ||
| 324 | workspace_states: HashMap<ThreadWorktreesId, WorkspaceState>, | ||
| 325 | active_workspace_state_id: ThreadWorktreesId, | ||
| 326 | |||
| 327 | // Docks stay constant - but panel contents come from active WorkspaceState | ||
| 328 | left_dock: Entity<Dock>, | ||
| 329 | bottom_dock: Entity<Dock>, | ||
| 330 | right_dock: Entity<Dock>, | ||
| 331 | |||
| 332 | // These stay constant across switches | ||
| 333 | status_bar: Entity<StatusBar>, | ||
| 334 | modal_layer: Entity<ModalLayer>, | ||
| 335 | toast_layer: Entity<ToastLayer>, | ||
| 336 | titlebar_item: Option<AnyView>, | ||
| 337 | notifications: Notifications, | ||
| 338 | // ... | ||
| 339 | } | ||
| 340 | ``` | ||
| 341 | |||
| 342 | ### 2.4 Switching Active ThreadWorktrees | ||
| 343 | |||
| 344 | When switching threads: | ||
| 345 | |||
| 346 | 1. **Save current state** - Store current center/panes in `workspace_states` | ||
| 347 | 2. **Update Project** - Set `active_thread_worktrees` to new ThreadWorktreesId | ||
| 348 | 3. **Load/create WorkspaceState** - Either restore existing or create new | ||
| 349 | 4. **Swap dock panels** - Update docks to show panels from new WorkspaceState | ||
| 350 | 5. **Update status bar** - Refresh to reflect new active worktrees | ||
| 351 | ```rust | ||
| 352 | impl Workspace { | ||
| 353 | pub fn switch_to_thread( | ||
| 354 | &mut self, | ||
| 355 | thread_worktrees_id: ThreadWorktreesId, | ||
| 356 | window: &mut Window, | ||
| 357 | cx: &mut Context<Self>, | ||
| 358 | ) { | ||
| 359 | // 1. Save current state | ||
| 360 | self.save_active_workspace_state(cx); | ||
| 361 | |||
| 362 | // 2. Ensure worktrees are loaded in Project | ||
| 363 | self.project.update(cx, |project, cx| { | ||
| 364 | project.ensure_thread_worktrees_loaded(&thread_worktrees_id, cx); | ||
| 365 | }); | ||
| 366 | |||
| 367 | // 3. Load or create WorkspaceState | ||
| 368 | let state = self.workspace_states | ||
| 369 | .entry(thread_worktrees_id.clone()) | ||
| 370 | .or_insert_with(|| WorkspaceState::new(thread_worktrees_id.clone(), cx)); | ||
| 371 | |||
| 372 | // 4. Update active state | ||
| 373 | self.active_workspace_state_id = thread_worktrees_id; | ||
| 374 | |||
| 375 | // 5. Update UI | ||
| 376 | self.update_docks_for_active_state(window, cx); | ||
| 377 | self.update_window_title(window, cx); | ||
| 378 | cx.notify(); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | ``` | ||
| 382 | |||
| 383 | |||
| 384 | ### 2.5 Panel Instance Management | ||
| 385 | |||
| 386 | Each ThreadWorktrees gets its own panel instances: | ||
| 387 | |||
| 388 | ```rust | ||
| 389 | impl WorkspaceState { | ||
| 390 | fn get_or_create_panel<P: Panel>(&mut self, cx: &mut App) -> Entity<P> { | ||
| 391 | let type_id = TypeId::of::<P>(); | ||
| 392 | if let Some(panel) = self.panel_instances.get(&type_id) { | ||
| 393 | panel.downcast::<P>().unwrap() | ||
| 394 | } else { | ||
| 395 | let panel = cx.new(|cx| P::new(/* ... */)); | ||
| 396 | self.panel_instances.insert(type_id, Box::new(panel.clone())); | ||
| 397 | panel | ||
| 398 | } | ||
| 399 | } | ||
| 400 | } | ||
| 401 | ``` | ||
| 402 | |||
| 403 | ### 2.6 Feature-Flagged Action | ||
| 404 | |||
| 405 | Add action under `agent-v2` feature flag: | ||
| 406 | |||
| 407 | - `SwitchToThread(SessionId)` - Switches to a thread's ThreadWorktrees | ||
| 408 | - `OpenThread(SessionId)` - Opens a thread (loads its worktrees, creates WorkspaceState) | ||
| 409 | - Initially test with hardcoded thread data | ||
| 410 | |||
| 411 | --- | ||
| 412 | |||
| 413 | ## Phase 3: Agents Sidebar | ||
| 414 | |||
| 415 | Create a new sidebar on the far left (outside the workspace/docks), replace AgentsPanel, and remove utility pane concept. | ||
| 416 | |||
| 417 | ### 3.1 Window Layout | ||
| 418 | |||
| 419 | ``` | ||
| 420 | ┌──────────┬──────────────────────────────────────────────────┐ | ||
| 421 | │ │ │ | ||
| 422 | │ Agents │ ┌─────────┬──────────────────┬────────┐ │ | ||
| 423 | │ Sidebar │ │ Left │ │ Right │ │ | ||
| 424 | │ │ │ Dock │ Center Panes │ Dock │ │ | ||
| 425 | │ ──────── │ │ │ │ │ │ | ||
| 426 | │ Thread │ │ ─────── │ │ │ │ | ||
| 427 | │ List │ │ Agent │ │ │ │ | ||
| 428 | │ │ │ Panel │ │ │ │ | ||
| 429 | │ ──────── │ │ (thread │ │ │ │ | ||
| 430 | │ Running │ │ view) │ │ │ │ | ||
| 431 | │ Threads │ │ │ │ │ │ | ||
| 432 | │ │ └─────────┴──────────────────┴────────┘ │ | ||
| 433 | └──────────┴──────────────────────────────────────────────────┘ | ||
| 434 | ``` | ||
| 435 | |||
| 436 | **AgentsSidebar** (constant, outside workspace): | ||
| 437 | |||
| 438 | - Far left, before left dock | ||
| 439 | - Resizable (like docks) | ||
| 440 | - Contains thread list/history and running threads section | ||
| 441 | - Does NOT swap with ThreadWorktrees | ||
| 442 | |||
| 443 | **AgentPanel** (in left dock, swaps with ThreadWorktrees): | ||
| 444 | |||
| 445 | - Shows single thread view (the thread associated with active ThreadWorktrees) | ||
| 446 | - Each ThreadWorktrees has its own AgentPanel instance | ||
| 447 | - Simpler than current AgentsPanel (no history, just thread view) | ||
| 448 | |||
| 449 | ### 3.2 AgentsSidebar Component | ||
| 450 | |||
| 451 | Create new [`crates/agent_ui_v2/src/agents_sidebar.rs`](crates/agent_ui_v2/src/agents_sidebar.rs): | ||
| 452 | |||
| 453 | ```rust | ||
| 454 | pub struct AgentsSidebar { | ||
| 455 | width: Pixels, | ||
| 456 | thread_history: Entity<AcpThreadHistory>, // Reuse existing | ||
| 457 | running_threads: Vec<RunningThread>, | ||
| 458 | focus_handle: FocusHandle, | ||
| 459 | } | ||
| 460 | |||
| 461 | struct RunningThread { | ||
| 462 | session_id: acp::SessionId, | ||
| 463 | thread_worktrees_id: ThreadWorktreesId, | ||
| 464 | thread: Entity<Thread>, // Keeps running in background | ||
| 465 | title: SharedString, | ||
| 466 | status: ThreadStatus, // Running, Idle, Error, etc. | ||
| 467 | } | ||
| 468 | |||
| 469 | impl AgentsSidebar { | ||
| 470 | /// Called when user clicks a thread in the list | ||
| 471 | fn open_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) { | ||
| 472 | // 1. Check folder availability via GlobalThreadStore | ||
| 473 | // 2. If available, switch to thread's ThreadWorktrees | ||
| 474 | // 3. Show thread in AgentPanel | ||
| 475 | } | ||
| 476 | |||
| 477 | /// Called when user clicks a running thread | ||
| 478 | fn switch_to_running_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) { | ||
| 479 | // Switch to that thread's ThreadWorktrees AND show in AgentPanel | ||
| 480 | // (Multiple threads can share the same ThreadWorktrees) | ||
| 481 | } | ||
| 482 | } | ||
| 483 | ``` | ||
| 484 | |||
| 485 | ### 3.3 AgentPanel (Simplified) | ||
| 486 | |||
| 487 | Replace current AgentsPanel with simpler AgentPanel in [`crates/agent_ui_v2/src/agent_panel.rs`](crates/agent_ui_v2/src/agent_panel.rs): | ||
| 488 | |||
| 489 | ```rust | ||
| 490 | /// Panel in left dock that shows the thread view for active ThreadWorktrees | ||
| 491 | pub struct AgentPanel { | ||
| 492 | thread_view: Option<Entity<AcpThreadView>>, | ||
| 493 | thread_worktrees_id: ThreadWorktreesId, | ||
| 494 | // No history - that's in the sidebar now | ||
| 495 | } | ||
| 496 | |||
| 497 | impl AgentPanel { | ||
| 498 | /// Shows thread associated with the current ThreadWorktrees | ||
| 499 | fn show_thread(&mut self, thread: &AgentSessionInfo, cx: &mut Context<Self>) { | ||
| 500 | // Create/update AcpThreadView | ||
| 501 | } | ||
| 502 | } | ||
| 503 | ``` | ||
| 504 | |||
| 505 | **Key difference from current AgentsPanel:** | ||
| 506 | |||
| 507 | - No thread history (moved to sidebar) | ||
| 508 | - No utility pane management (removed) | ||
| 509 | - One instance per ThreadWorktrees (swaps with workspace state) | ||
| 510 | |||
| 511 | ### 3.4 Remove Utility Pane Concept | ||
| 512 | |||
| 513 | Delete or deprecate: | ||
| 514 | |||
| 515 | - `AgentThreadPane` (`crates/agent_ui_v2/src/agent_thread_pane.rs`) | ||
| 516 | - Utility pane infrastructure in workspace | ||
| 517 | |||
| 518 | ### 3.5 Running Threads Section | ||
| 519 | |||
| 520 | ```rust | ||
| 521 | impl AgentsSidebar { | ||
| 522 | fn render_running_threads(&self, cx: &Context<Self>) -> impl IntoElement { | ||
| 523 | v_flex() | ||
| 524 | .child(Label::new("Running Threads").size(LabelSize::Small)) | ||
| 525 | .children(self.running_threads.iter().map(|rt| { | ||
| 526 | ListItem::new(rt.session_id.clone()) | ||
| 527 | .child(Label::new(rt.title.clone())) | ||
| 528 | .child(ThreadStatusIndicator::new(rt.status)) | ||
| 529 | .on_click(cx.listener(move |this, _, _, cx| { | ||
| 530 | this.switch_to_running_thread(&rt.session_id, cx); | ||
| 531 | })) | ||
| 532 | })) | ||
| 533 | } | ||
| 534 | } | ||
| 535 | ``` | ||
| 536 | |||
| 537 | ### 3.6 Integration with Workspace | ||
| 538 | |||
| 539 | Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs) to add sidebar: | ||
| 540 | |||
| 541 | ```rust | ||
| 542 | pub struct Workspace { | ||
| 543 | // NEW: Sidebar lives outside the swappable content | ||
| 544 | agents_sidebar: Option<Entity<AgentsSidebar>>, | ||
| 545 | agents_sidebar_width: Pixels, | ||
| 546 | |||
| 547 | // Existing (docks swap their panel contents) | ||
| 548 | left_dock: Entity<Dock>, | ||
| 549 | // ... | ||
| 550 | } | ||
| 551 | |||
| 552 | impl Workspace { | ||
| 553 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | ||
| 554 | h_flex() | ||
| 555 | .size_full() | ||
| 556 | // Sidebar first (far left) | ||
| 557 | .when_some(self.agents_sidebar.as_ref(), |this, sidebar| { | ||
| 558 | this.child(sidebar.clone()) | ||
| 559 | }) | ||
| 560 | // Then the rest of workspace (docks + center) | ||
| 561 | .child(self.render_workspace_content(window, cx)) | ||
| 562 | } | ||
| 563 | } | ||
| 564 | ``` | ||
| 565 | |||
| 566 | ### 3.7 Thread Association UI | ||
| 567 | |||
| 568 | Add UI for associating threads with project folders (deferred from Phase 1): | ||
| 569 | |||
| 570 | - **Thread context menu** - Right-click thread in sidebar → "Associate with current project" | ||
| 571 | - **Sidebar action** - Button in thread list header | ||
| 572 | - **New thread prompt** - When creating thread, prompt to associate | ||
| 573 | ```rust | ||
| 574 | impl AgentsSidebar { | ||
| 575 | fn associate_thread_with_project( | ||
| 576 | &mut self, | ||
| 577 | session_id: &acp::SessionId, | ||
| 578 | cx: &mut Context<Self>, | ||
| 579 | ) { | ||
| 580 | // Get current window's worktree paths from Project | ||
| 581 | // Update ThreadStore with association | ||
| 582 | // Refresh thread availability indicators | ||
| 583 | } | ||
| 584 | } | ||
| 585 | ``` | ||
| 586 | |||
| 587 | |||
| 588 | --- | ||
| 589 | |||
| 590 | ## Architecture Diagram | ||
| 591 | |||
| 592 | ```mermaid | ||
| 593 | graph TB | ||
| 594 | subgraph app [Application] | ||
| 595 | GTS[GlobalThreadStore] | ||
| 596 | DB[(ThreadsDatabase)] | ||
| 597 | end | ||
| 598 | |||
| 599 | subgraph window [Window - MultiWorkspace] | ||
| 600 | subgraph constant [Constant - Outside Workspace] | ||
| 601 | Sidebar[AgentsSidebar] | ||
| 602 | SidebarHistory[Thread History] | ||
| 603 | SidebarRunning[Running Threads] | ||
| 604 | end | ||
| 605 | |||
| 606 | subgraph swappable [Swappable - Per ThreadWorktrees] | ||
| 607 | subgraph wsA [WorkspaceState A] | ||
| 608 | CenterA[Center Panes A] | ||
| 609 | AgentPanelA[AgentPanel A] | ||
| 610 | ProjectPanelA[ProjectPanel A] | ||
| 611 | end | ||
| 612 | subgraph wsB [WorkspaceState B] | ||
| 613 | CenterB[Center Panes B] | ||
| 614 | AgentPanelB[AgentPanel B] | ||
| 615 | ProjectPanelB[ProjectPanel B] | ||
| 616 | end | ||
| 617 | end | ||
| 618 | |||
| 619 | Project[Project - all worktrees] | ||
| 620 | end | ||
| 621 | |||
| 622 | GTS --> DB | ||
| 623 | Sidebar --> GTS | ||
| 624 | Sidebar --> SidebarHistory | ||
| 625 | Sidebar --> SidebarRunning | ||
| 626 | SidebarRunning --> wsA | ||
| 627 | SidebarRunning --> wsB | ||
| 628 | Project --> swappable | ||
| 629 | ``` | ||
| 630 | |||
| 631 | ## Window Layout | ||
| 632 | |||
| 633 | ``` | ||
| 634 | ┌──────────┬──────────────────────────────────────────────────┐ | ||
| 635 | │ Agents │ ┌─────────┬──────────────────┬────────┐ │ | ||
| 636 | │ Sidebar │ │ Left │ │ Right │ │ | ||
| 637 | │ │ │ Dock │ Center Panes │ Dock │ │ | ||
| 638 | │ ──────── │ │ │ (swappable) │ │ │ | ||
| 639 | │ Thread │ │ Agent │ │ │ │ | ||
| 640 | │ History │ │ Panel │ │ │ │ | ||
| 641 | │ │ │ (swap) │ │ │ │ | ||
| 642 | │ ──────── │ │ │ │ │ │ | ||
| 643 | │ Running │ │ Project │ │ │ │ | ||
| 644 | │ Threads │ │ Panel │ │ │ │ | ||
| 645 | │ │ │ (swap) │ │ │ │ | ||
| 646 | │(constant)│ └─────────┴──────────────────┴────────┘ │ | ||
| 647 | └──────────┴──────────────────────────────────────────────────┘ | ||
| 648 | ↑ ↑ | ||
| 649 | Outside Inside Workspace | ||
| 650 | Workspace (swaps with ThreadWorktrees) | ||
| 651 | ``` | ||
| 652 | |||
| 653 | ## Key Concepts | ||
| 654 | |||
| 655 | ``` | ||
| 656 | Window (MultiWorkspace) | ||
| 657 | └── Project (singular) | ||
| 658 | ├── Worktrees: [/zed, /cloud, /zed.dev] ← All loaded worktrees | ||
| 659 | ├── ThreadWorktrees A: [/zed, /cloud] ← Thread A's folders | ||
| 660 | ├── ThreadWorktrees B: [/cloud, /zed.dev] ← Thread B's folders | ||
| 661 | └── active_thread_worktrees: A | ||
| 662 | |||
| 663 | └── Workspace | ||
| 664 | ├── workspace_states: | ||
| 665 | │ ├── A → WorkspaceState (center panes, panels for Thread A) | ||
| 666 | │ └── B → WorkspaceState (center panes, panels for Thread B) | ||
| 667 | ├── active_workspace_state_id: A | ||
| 668 | └── Docks (constant, panels swapped from active WorkspaceState) | ||
| 669 | ``` | ||
| 670 | |||
| 671 | ## Folder Ownership Model | ||
| 672 | |||
| 673 | ``` | ||
| 674 | Thread A: folders {zed, cloud} | ||
| 675 | Thread B: folders {cloud, zed.dev} | ||
| 676 | |||
| 677 | Window 1 opens Thread A: | ||
| 678 | → Window 1 owns: {zed, cloud} | ||
| 679 | → Thread B unavailable in Window 2 (shares "cloud") | ||
| 680 | → Thread B available in Window 1 (same window, can open both) | ||
| 681 | ``` | ||
| 682 | |||
| 683 | --- | ||
| 684 | |||
| 685 | ## Key Files | ||
| 686 | |||
| 687 | | File | Phase | Changes | | ||
| 688 | |------|-------|---------| | ||
| 689 | | [`crates/agent/src/thread_store.rs`](crates/agent/src/thread_store.rs) | 1 | Global wrapper, ownership tracking | | ||
| 690 | | [`crates/agent/src/db.rs`](crates/agent/src/db.rs) | 1 | Add project_paths to schema | | ||
| 691 | | [`crates/zed/src/main.rs`](crates/zed/src/main.rs) | 1 | ThreadStore::init() | | ||
| 692 | | [`crates/project/src/project.rs`](crates/project/src/project.rs) | 2 | ThreadWorktrees struct, ThreadWorktreesId | | ||
| 693 | | [`crates/project/src/thread_worktrees.rs`](crates/project/src/thread_worktrees.rs) | 2 | NEW: ThreadWorktrees API surface, event filtering | | ||
| 694 | | [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs) | 2, 3 | WorkspaceState, AgentsSidebar integration | | ||
| 695 | | [`crates/git_ui/src/git_panel.rs`](crates/git_ui/src/git_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 696 | | [`crates/outline_panel/src/outline_panel.rs`](crates/outline_panel/src/outline_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 697 | | [`crates/project_panel/src/project_panel.rs`](crates/project_panel/src/project_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 698 | | [`crates/agent_ui/src/agent_panel.rs`](crates/agent_ui/src/agent_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | ||
| 699 | | [`crates/agent_ui_v2/src/agents_sidebar.rs`](crates/agent_ui_v2/src/agents_sidebar.rs) | 3 | NEW: AgentsSidebar component | | ||
| 700 | | [`crates/agent_ui_v2/src/agent_panel.rs`](crates/agent_ui_v2/src/agent_panel.rs) | 3 | Simplified thread view panel | | ||
| 701 | | [`crates/agent_ui_v2/src/agents_panel.rs`](crates/agent_ui_v2/src/agents_panel.rs) | 3 | DELETE or heavily refactor | | ||
| 702 | | [`crates/agent_ui_v2/src/agent_thread_pane.rs`](crates/agent_ui_v2/src/agent_thread_pane.rs) | 3 | DELETE (utility pane removed) | | ||
| 703 | |||
| 704 | --- | ||
| 705 | |||
| 706 | ## Design Decisions | ||
| 707 | |||
| 708 | 1. **Thread-project association is explicit** - Users must explicitly associate threads with projects (UI in Phase 3). No automatic association on thread creation. | ||
| 709 | |||
| 710 | 2. **Existing threads have empty paths** - Backwards compatible: threads without `project_paths` are treated as available everywhere (no folder ownership conflicts). | ||
| 711 | |||
| 712 | 3. **Missing paths prompt reassociation** - If a thread's associated folders no longer exist on disk, prompt user to re-associate when opening. | ||
| 713 | |||
| 714 | 4. **Association scope** - When associating, all worktrees in the current project are associated. | ||
| 715 | |||
| 716 | 5. **Association UI entry points** (Phase 3): | ||
| 717 | - Thread context menu | ||
| 718 | - Sidebar action | ||
| 719 | - Auto-prompt on new thread creation | ||
| 720 | |||
| 721 | 6. **ThreadWorktrees as panel API surface** - Panels hold `Entity<ThreadWorktrees>` instead of `Entity<Project>`. This provides: | ||
| 722 | - **Type safety**: Panels can't accidentally access wrong scope | ||
| 723 | - **Centralized event filtering**: ThreadWorktrees filters git/worktree events before re-emitting | ||
| 724 | - **Clear API boundary**: Scoped operations (worktrees, git, files) vs pass-through (is_via_collab, languages) | ||
| 725 | |||
| 726 | 7. **Project remains singular** - One `Entity<Project>` per window holds all worktrees. ThreadWorktrees is a filtered lens, not a separate data store. Worktrees shared between threads exist once in Project. | ||
| 727 | |||
| 728 | 8. **Panel migration strategy** - Panels migrate incrementally: | ||
| 729 | - Replace `project: Entity<Project>` with `thread_worktrees: Entity<ThreadWorktrees>` | ||
| 730 | - Replace `cx.subscribe(&git_store, ...)` with `cx.subscribe(&thread_worktrees, ...)` | ||
| 731 | - Scoped methods have same signatures, making migration mechanical | ||
| 732 | |||
| 733 | --- | ||
| 734 | |||
| 735 | ## Open Questions | ||
| 736 | |||
| 737 | *All questions resolved!* | ||
| 738 | |||
| 739 | --- | ||
| 740 | |||
| 741 | ## Resolved Questions | ||
| 742 | |||
| 743 | ### R1. ThreadWorktrees and buffer_store | ||
| 744 | |||
| 745 | **Resolution: SHARED (pass-through)** | ||
| 746 | |||
| 747 | `buffer_store()` is shared across all ThreadWorktrees, not scoped. Rationale: | ||
| 748 | |||
| 749 | - **Buffers represent files, which are singular** - BufferStore already deduplicates by `ProjectPath`. Opening the same file twice returns the same `Entity<Buffer>`. | ||
| 750 | - **Shared worktrees mean shared files** - If Thread A and B both have `/cloud`, edits to `/cloud/src/main.rs` should be visible in both immediately. | ||
| 751 | - **Scoping is about visibility, not isolation** - ThreadWorktrees controls which worktrees appear in UI, not parallel editing universes. | ||
| 752 | - **LSP and collab depend on shared buffers** - Language servers work per-file, remote collaboration shares buffers by ID. | ||
| 753 | |||
| 754 | Implementation: | ||
| 755 | ```rust | ||
| 756 | impl ThreadWorktrees { | ||
| 757 | // Pass-through - buffers are shared | ||
| 758 | pub fn buffer_store(&self, cx: &App) -> Entity<BufferStore> { | ||
| 759 | self.project.read(cx).buffer_store().clone() | ||
| 760 | } | ||
| 761 | |||
| 762 | // Convenience with scope validation (workflow guard, not isolation) | ||
| 763 | pub fn open_buffer(&self, path: ProjectPath, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> { | ||
| 764 | if !self.worktree_ids.contains(&path.worktree_id) { | ||
| 765 | return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | ||
| 766 | } | ||
| 767 | self.project.update(cx, |project, cx| project.open_buffer(path, cx)) | ||
| 768 | } | ||
| 769 | } | ||
| 770 | ``` | ||
| 771 | |||
| 772 | ### R2. Cross-thread file references | ||
| 773 | |||
| 774 | **Resolution: Center panes are NOT scoped, only panels are** | ||
| 775 | |||
| 776 | When "Go to Definition" (or similar navigation) targets a file outside the current ThreadWorktrees: | ||
| 777 | |||
| 778 | - **If the worktree is loaded** (another thread is live) → **Just works**. The file opens in a center tab. ProjectPanel won't show it in its tree (filtered by ThreadWorktrees), but the editor works normally. | ||
| 779 | |||
| 780 | - **If the worktree is NOT loaded** → **File not available**. Show message: "Definition is in `/path` which is not currently loaded." | ||
| 781 | |||
| 782 | Rationale: | ||
| 783 | - **Project** holds ALL worktrees from ALL live threads | ||
| 784 | - **ThreadWorktrees** is a filtered *view* for panels only | ||
| 785 | - **Center panes** (editors, tabs) can show any buffer from any loaded worktree | ||
| 786 | - This matches current Zed behavior - you can have open files that aren't in the project panel (via drag-drop, external open, etc.) | ||
| 787 | |||
| 788 | Architecture: | ||
| 789 | ``` | ||
| 790 | ┌─────────────────────────────────────────────────────────┐ | ||
| 791 | │ Window │ | ||
| 792 | ├──────────┬──────────────────────────────────────────────┤ | ||
| 793 | │ Agents │ ┌─────────┬──────────────────┬────────┐ │ | ||
| 794 | │ Sidebar │ │ Left │ │ Right │ │ | ||
| 795 | │ │ │ Dock │ Center Panes │ Dock │ │ | ||
| 796 | │ │ │ │ │ │ │ | ||
| 797 | │ │ │ SCOPED │ NOT SCOPED │ SCOPED │ │ | ||
| 798 | │ │ │ (shows │ (can open any │ │ │ | ||
| 799 | │ │ │ only │ buffer from │ │ │ | ||
| 800 | │ │ │ Thread │ any loaded │ │ │ | ||
| 801 | │ │ │ A's │ worktree) │ │ │ | ||
| 802 | │ │ │ trees) │ │ │ │ | ||
| 803 | │ │ └─────────┴──────────────────┴────────┘ │ | ||
| 804 | └──────────┴──────────────────────────────────────────────┘ | ||
| 805 | ``` | ||
| 806 | |||
| 807 | ### R3. ThreadWorktrees lifecycle | ||
| 808 | |||
| 809 | **Resolution: Reference counting handles cleanup automatically** | ||
| 810 | |||
| 811 | GPUI entities are reference counted. No special "closed" state or explicit cleanup logic needed. | ||
| 812 | |||
| 813 | **Thread states:** | ||
| 814 | - **Active**: Currently shown in UI | ||
| 815 | - **Backgrounded**: Still alive (in `workspace_states` hashmap), not currently shown | ||
| 816 | |||
| 817 | **Cleanup flow when closing a thread:** | ||
| 818 | 1. Remove `WorkspaceState` from `workspace_states` hashmap | ||
| 819 | 2. WorkspaceState is dropped, which drops panel instances | ||
| 820 | 3. Panels drop their `Entity<ThreadWorktrees>` references | ||
| 821 | 4. ThreadWorktrees is automatically cleaned up by GPUI (refcount → 0) | ||
| 822 | 5. Worktrees may stay in Project (if referenced by other threads) or get cleaned up similarly | ||
| 823 | |||
| 824 | No explicit `remove_thread_worktrees()` call needed - just drop the WorkspaceState and reference counting handles the rest. | ||
| 825 | |||
| 826 | ### R4. Panel state preservation | ||
| 827 | |||
| 828 | **Resolution: Preserved by design** | ||
| 829 | |||
| 830 | Each ThreadWorktrees has its own panel instances stored in `WorkspaceState.panel_instances`. When switching threads: | ||
| 831 | - Current WorkspaceState (with all panel state) stays in the `workspace_states` hashmap | ||
| 832 | - New ThreadWorktrees' WorkspaceState is restored (or created) | ||
| 833 | - Switch back → original panel state is intact (scroll positions, selections, etc.) | ||
| 834 | |||
| 835 | No extra work needed - the architecture inherently preserves panel state. | ||
| 836 | |||
| 837 | ### R5. Global vs scoped panels | ||
| 838 | |||
| 839 | **Resolution: Most panels are scoped, a few stay global** | ||
| 840 | |||
| 841 | **SCOPED (per-ThreadWorktrees)** - stored in `WorkspaceState.panel_instances`: | ||
| 842 | | Panel | Reason | | ||
| 843 | |-------|--------| | ||
| 844 | | ProjectPanel | Shows worktree file trees | | ||
| 845 | | GitPanel | Shows repos tied to worktrees | | ||
| 846 | | OutlinePanel | Shows context for files in worktrees | | ||
| 847 | | DebugPanel | Debug sessions are project-specific | | ||
| 848 | | TerminalPanel | Terminals have working directories in worktrees | | ||
| 849 | | AgentPanel | Thread-specific conversation | | ||
| 850 | |||
| 851 | **GLOBAL (shared)** - stored on `Workspace` directly (current behavior): | ||
| 852 | | Panel | Reason | | ||
| 853 | |-------|--------| | ||
| 854 | | CollabPanel | Collaborators, channels, contacts - user's social state | | ||
| 855 | | NotificationPanel | User's notifications across all projects | | ||
| 856 | |||
| 857 | **SPECIAL:** | ||
| 858 | | Panel | Reason | | ||
| 859 | |-------|--------| | ||
| 860 | | AgentsSidebar | Lives outside workspace/dock system entirely | | ||
| 861 | |||
| 862 | **Implementation note:** `WorkspaceState` should be an `Entity<WorkspaceState>` so that `zed.rs` can initialize scoped panels using the existing `initialize_panels` pattern. This: | ||
| 863 | - Keeps the initialization flow in one place (zed.rs) | ||
| 864 | - Solves cyclic reference problems (panels reference workspace, workspace references panels) | ||
| 865 | - Matches the existing pattern that already works | ||
| 866 | |||
| 867 | ```rust | ||
| 868 | // In zed.rs - similar to current initialize_panels but per-WorkspaceState | ||
| 869 | fn initialize_scoped_panels( | ||
| 870 | workspace_state: WeakEntity<WorkspaceState>, | ||
| 871 | thread_worktrees: Entity<ThreadWorktrees>, | ||
| 872 | cx: AsyncWindowContext, | ||
| 873 | ) { | ||
| 874 | cx.spawn(async move |cx| { | ||
| 875 | let project_panel = ProjectPanel::load(thread_worktrees.clone(), cx.clone()); | ||
| 876 | let git_panel = GitPanel::load(thread_worktrees.clone(), cx.clone()); | ||
| 877 | // ... other scoped panels | ||
| 878 | |||
| 879 | futures::join!( | ||
| 880 | add_panel_when_ready(project_panel, workspace_state.clone(), cx.clone()), | ||
| 881 | add_panel_when_ready(git_panel, workspace_state.clone(), cx.clone()), | ||
| 882 | // ... | ||
| 883 | ); | ||
| 884 | }).detach(); | ||
| 885 | } | ||
| 886 | ``` | ||
| 887 | |||
| 888 | Global panels (CollabPanel, NotificationPanel) continue to be initialized once per Workspace as they are today. | ||
| 889 | |||
| 890 | ### R6. Sidebar persistence | ||
| 891 | |||
| 892 | **Resolution: Serialize alongside MultiWorkspace state** | ||
| 893 | |||
| 894 | Sidebar state should be persisted to the database alongside the MultiWorkspace state. Rationale: | ||
| 895 | - One MultiWorkspace per Sidebar (per window) - their lifecycles are tied | ||
| 896 | - State should be co-located for consistency | ||
| 897 | - Enables full restore of window state on restart | ||
| 898 | |||
| 899 | **What to persist:** | ||
| 900 | - Sidebar width | ||
| 901 | - Expanded/collapsed sections (thread history, running threads) | ||
| 902 | - Scroll position in thread list | ||
| 903 | - Any filter/search state | ||
| 904 | |||
| 905 | **Where to store:** | ||
| 906 | Alongside the existing workspace serialization in the DB, keyed by `WorkspaceId`. The `SerializedWorkspace` struct (or a new sibling) would include sidebar state. | ||
| 907 | |||
| 908 | ```rust | ||
| 909 | struct SerializedMultiWorkspace { | ||
| 910 | // Existing workspace fields... | ||
| 911 | |||
| 912 | // Sidebar state | ||
| 913 | sidebar_width: Option<Pixels>, | ||
| 914 | sidebar_expanded_sections: Vec<String>, | ||
| 915 | sidebar_scroll_position: Option<f32>, | ||
| 916 | |||
| 917 | // Per-ThreadWorktrees state | ||
| 918 | thread_workspace_states: HashMap<ThreadWorktreesId, SerializedWorkspaceState>, | ||
| 919 | active_thread_worktrees_id: Option<ThreadWorktreesId>, | ||
| 920 | } | ||
| 921 | ``` | ||
| @@ -1,154 +0,0 @@ | |||
| 1 | # Utility Pane Removal Plan | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | The utility pane is a secondary pane system that sits alongside the main editor panes. It allows panels (specifically `AgentThreadPane`) to "pop out" a detail view next to the center editor area. It supports two slots: | ||
| 6 | |||
| 7 | - **Left slot**: Adjacent to the left dock | ||
| 8 | - **Right slot**: Adjacent to the right dock | ||
| 9 | |||
| 10 | According to `threads-sidebar-plan.md`, this removal is part of **Phase 3** of the MultiWorkspace Agent V2 Refactor. The utility pane concept is being replaced by an "Agents Sidebar" that will live outside the workspace/docks structure. | ||
| 11 | |||
| 12 | --- | ||
| 13 | |||
| 14 | ## Files to Modify/Delete | ||
| 15 | |||
| 16 | ### 1. Core Infrastructure (workspace crate) | ||
| 17 | |||
| 18 | - `crates/workspace/src/utility_pane.rs` — **DELETE** | ||
| 19 | - Contains all utility pane state, types, and impl methods on Workspace | ||
| 20 | |||
| 21 | - `crates/workspace/src/dock.rs` — **EDIT** | ||
| 22 | - Remove `UtilityPane`, `UtilityPaneHandle`, `UtilityPanePosition`, `MinimizePane`, `ClosePane` | ||
| 23 | |||
| 24 | - `crates/workspace/src/workspace.rs` — **EDIT** | ||
| 25 | - Remove: `utility_panes` field, imports, `clamp_utility_pane_widths()`, `max_utility_pane_width()`, `UtilityPaneFrame` renders in `fn render()` | ||
| 26 | |||
| 27 | - `crates/workspace/src/pane.rs` — **EDIT** | ||
| 28 | - Remove utility pane toggle buttons from tab bar rendering | ||
| 29 | |||
| 30 | ### 2. Agent UI V2 (consumer of utility panes) | ||
| 31 | |||
| 32 | - `crates/agent_ui_v2/src/agent_thread_pane.rs` — **DELETE** | ||
| 33 | - The only implementer of `UtilityPane` trait | ||
| 34 | |||
| 35 | - `crates/agent_ui_v2/src/agents_panel.rs` — **EDIT** | ||
| 36 | - Remove all utility pane management (`register_utility_pane`, `restore_utility_pane`, subscriptions, etc.) | ||
| 37 | |||
| 38 | ### 3. Settings | ||
| 39 | |||
| 40 | - `crates/settings/src/settings_content/agent.rs` — **EDIT** | ||
| 41 | - Remove "utility pane" dock setting comment/option | ||
| 42 | |||
| 43 | --- | ||
| 44 | |||
| 45 | ## Types and Traits to Remove | ||
| 46 | |||
| 47 | ### From `crates/workspace/src/dock.rs` | ||
| 48 | |||
| 49 | - `MinimizePane` (struct) | ||
| 50 | - `ClosePane` (struct) | ||
| 51 | - `UtilityPane` (trait) | ||
| 52 | - `UtilityPaneHandle` (trait) | ||
| 53 | - `UtilityPanePosition` (enum) | ||
| 54 | |||
| 55 | ### From `crates/workspace/src/utility_pane.rs` (entire file) | ||
| 56 | |||
| 57 | - `UtilityPaneSlot` (enum) | ||
| 58 | - `UtilityPaneSlotState` (struct) | ||
| 59 | - `UtilityPaneState` (struct) | ||
| 60 | - `DraggedUtilityPane` (struct) | ||
| 61 | - `UtilityPaneFrame` (struct) | ||
| 62 | - `utility_slot_for_dock_position()` (function) | ||
| 63 | - `UTILITY_PANE_RESIZE_HANDLE_SIZE` (const) | ||
| 64 | - `UTILITY_PANE_MIN_WIDTH` (const) | ||
| 65 | - All `impl Workspace` methods for utility panes | ||
| 66 | |||
| 67 | --- | ||
| 68 | |||
| 69 | ## Methods on `Workspace` to Remove | ||
| 70 | |||
| 71 | - `utility_pane()` — Get a utility pane by slot | ||
| 72 | - `toggle_utility_pane()` — Toggle expanded state | ||
| 73 | - `register_utility_pane()` — Register a new utility pane | ||
| 74 | - `clear_utility_pane()` — Clear a utility pane slot | ||
| 75 | - `clear_utility_pane_if_provider()` — Conditionally clear if owned by panel | ||
| 76 | - `resize_utility_pane()` — Resize a utility pane | ||
| 77 | - `reset_utility_pane_width()` — Reset to default width | ||
| 78 | - `clamp_utility_pane_widths()` — Clamp widths when docks resize | ||
| 79 | - `max_utility_pane_width()` — Calculate max allowed width | ||
| 80 | |||
| 81 | --- | ||
| 82 | |||
| 83 | ## Render Changes in `workspace.rs` | ||
| 84 | |||
| 85 | The `render()` method has **8 places** where `UtilityPaneFrame::new()` is conditionally rendered based on dock positions. All of these need to be removed: | ||
| 86 | |||
| 87 | - Line ~7244: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | ||
| 88 | - Line ~7294: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | ||
| 89 | - Line ~7333: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | ||
| 90 | - Line ~7368: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | ||
| 91 | - Line ~7400: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | ||
| 92 | - Line ~7447: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | ||
| 93 | - Line ~7474: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | ||
| 94 | - Line ~7519: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | ||
| 95 | |||
| 96 | Also remove: | ||
| 97 | - The `DragMoveEvent<DraggedUtilityPane>` handler (~line 7195) | ||
| 98 | - The `DraggedUtilityPane` import | ||
| 99 | |||
| 100 | --- | ||
| 101 | |||
| 102 | ## Implementation Order | ||
| 103 | |||
| 104 | ### Phase 1: Remove Consumer (agent_ui_v2) | ||
| 105 | |||
| 106 | 1. Delete `crates/agent_ui_v2/src/agent_thread_pane.rs` | ||
| 107 | 2. Edit `crates/agent_ui_v2/src/agents_panel.rs`: | ||
| 108 | - Remove imports from `workspace::dock` and `workspace::utility_pane` | ||
| 109 | - Remove `agent_thread_pane` field and related fields | ||
| 110 | - Remove `restore_utility_pane()` method | ||
| 111 | - Remove `handle_utility_pane_event()` method | ||
| 112 | - Remove `handle_close_pane_event()` method | ||
| 113 | - Remove utility pane registration in `open_thread()` | ||
| 114 | - Remove `utility_slot()` method | ||
| 115 | - Update `serialize()` to not include pane state | ||
| 116 | |||
| 117 | ### Phase 2: Remove Infrastructure (workspace) | ||
| 118 | |||
| 119 | 1. Delete `crates/workspace/src/utility_pane.rs` | ||
| 120 | 2. Edit `crates/workspace/src/dock.rs`: | ||
| 121 | - Remove `MinimizePane`, `ClosePane` structs | ||
| 122 | - Remove `UtilityPane` trait | ||
| 123 | - Remove `UtilityPaneHandle` trait and impl | ||
| 124 | - Remove `UtilityPanePosition` enum | ||
| 125 | 3. Edit `crates/workspace/src/workspace.rs`: | ||
| 126 | - Remove `pub mod utility_pane;` declaration | ||
| 127 | - Remove utility_pane imports | ||
| 128 | - Remove `utility_panes: UtilityPaneState` field from `Workspace` struct | ||
| 129 | - Remove initialization in `Workspace::new()` | ||
| 130 | - Remove `clamp_utility_pane_widths()` calls in dock resize methods | ||
| 131 | - Remove `max_utility_pane_width()` method | ||
| 132 | - Remove `clamp_utility_pane_widths()` method | ||
| 133 | - Remove all `UtilityPaneFrame` renders in `fn render()` | ||
| 134 | - Remove `DragMoveEvent<DraggedUtilityPane>` handler | ||
| 135 | - Remove `clear_utility_pane_if_provider()` call in `remove_panel()` | ||
| 136 | 4. Edit `crates/workspace/src/pane.rs`: | ||
| 137 | - Remove `utility_pane::UtilityPaneSlot` import | ||
| 138 | - Remove `open_aside_left` rendering block in `render_tab_bar()` | ||
| 139 | - Remove `open_aside_right` rendering block in `render_tab_bar()` | ||
| 140 | |||
| 141 | ### Phase 3: Clean Up Settings | ||
| 142 | |||
| 143 | 1. Edit `crates/settings/src/settings_content/agent.rs`: | ||
| 144 | - Remove or update the "utility pane" dock setting documentation | ||
| 145 | |||
| 146 | --- | ||
| 147 | |||
| 148 | ## Testing | ||
| 149 | |||
| 150 | After removal: | ||
| 151 | 1. Run `cargo check -p workspace -p agent_ui_v2` | ||
| 152 | 2. Run `./script/clippy` | ||
| 153 | 3. Verify the workspace renders correctly without utility panes | ||
| 154 | 4. Verify the agents panel still functions (without the thread pane popup) | ||