History

Update multi-workspace refactor plan with simplified phased approach

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

Mikayla Maki committed 10 months ago

Commit 00a791c1 parent a7ba4ad6 files · +110 −2964Browse files

Unified · Split

6 files
  1. cross-workspace-threads-plan.md +0 −289
  2. multi-workspace-refactor-prompt.md +110 −125
  3. phase-2-multiworkspace-plan.md +0 −1067
  4. pluggable-shaders-gpui-plan.md +0 −408
  5. threads-sidebar-plan.md +0 −921
  6. utility-pane-removal-plan.md +0 −154
cross-workspace-threads-plan.md+0−289
@@ -1,289 +0,0 @@
1# Cross-Workspace Thread Handling Plan
2
3## Overview
4
5This 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
9Opening the same thread in multiple workspaces currently has significant issues:
10
111. **Flow disruption**: Opening a thread in two windows causes them to get out of sync
122. **Zombie threads**: Closing a window does NOT stop the thread from running - it continues processing in the background
134. **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
53Create a new **global** `AgentThreadStore` in a new `agent_2` crate that:
541. **Owns running threads** - holds strong references to keep them alive
552. **Tracks view state** - which window (if any) is viewing each thread
563. **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
63pub 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
80pub 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
89pub 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
101pub 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
108impl 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
170fn 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
205fn 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
220fn 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
2471. **Global store owns running threads**: Strong `Entity<AcpThread>` references keep threads alive
2482. **Background threads are a feature**: Closing a window detaches, doesn't kill
2493. **Single view per thread**: Only one window can view a thread at a time
2504. **Redirect, don't duplicate**: If a thread is open elsewhere, redirect to that window
251
252## Future Vision
253
254This refactor sets the foundation for a deeper architectural change: **threads as the primary object**.
255
256In 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
262This is similar to how modern AI coding tools treat "sessions" or "conversations" as first-class workspace containers.
263
264The current refactor provides immediate value by:
2651. ✅ Fixing the split-brain problem
2662. ✅ Enabling background threads as a feature
2673. ✅ Establishing the `AgentThreadStore` as the single source of truth
2684. ✅ Setting up the ownership model for the future vision
269
270## Implementation Plan
271
2721. [ ] Create new `agent_2` crate
2732. [ ] Implement `AgentThreadStore` with global access pattern
2743. [ ] Implement thread state tracking (`running_threads`, `thread_views`)
2754. [ ] Add `open_thread` / `detach_thread` / `stop_thread` APIs
2765. [ ] Wire up `AgentsPanel` (v2) to use `AgentThreadStore`
2776. [ ] Add window close handling to detach threads
2787. [ ] Update history view to show thread state (running/background/idle)
2798. [ ] Add tests for cross-workspace scenarios
2809. [ ] 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
multi-workspace-refactor-prompt.md+110−125
@@ -6,7 +6,7 @@ We are refactoring Zed to support multiple Workspaces that share worktrees. The · 5 unmodified lines
6 6
7### Current Architecture7### Current Architecture
8```8```
9Workspace9Workspace (being renamed to MultiWorkspace)
10└── Entity<Project>10└── Entity<Project>
11 └── Entity<WorktreeStore> // Project creates and owns this11 └── Entity<WorktreeStore> // Project creates and owns this
12 └── Entity<Worktree>...12 └── Entity<Worktree>...
@@ -16,10 +16,10 @@ Workspace · 3 unmodified lines
16 └── ... other stores16 └── ... other stores
17```17```
18 18
19### New Architecture19### Target Architecture
20```20```
21MultiWorkspace (new top-level container)21MultiWorkspace (window-level container)
22├── Entity<WorktreeStore> // Shared, contains mix of local AND remote worktrees22├── Entity<WorktreeStore> // Shared, eventually contains mix of local AND remote worktrees
23├── Entity<BufferStore> // Shared - same file = same buffer23├── Entity<BufferStore> // Shared - same file = same buffer
24├── Entity<GitStore> // Shared - git state is per-repo24├── Entity<GitStore> // Shared - git state is per-repo
25├── Entity<LspStore> // Shared - one LSP instance per worktree25├── 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 B38└── Workspace B
38 └── Entity<Project>39 └── Entity<Project>
@@ -40,40 +41,115 @@ MultiWorkspace (new top-level container) · 1 unmodified line
40 ├── client_state: ProjectClientState41 ├── 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
49The 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
90struct MultiWorkspace {
91 worktree_store: Entity<WorktreeStore>,
92 workspaces: Vec<Entity<Workspace>>,
93}
94```
95
96Then you can write tests like:
97
98```rust
99#[gpui::test]
100async 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):
1161. `WorktreeStore` - Remove `WorktreeStoreState` enum, push local/remote distinction into individual worktrees
1172. `BufferStore` - Lift to MultiWorkspace so same file = same buffer
1183. `GitStore` - Lift to MultiWorkspace (git state is per-repo)
1194. `LspStore` - Lift to MultiWorkspace (one LSP per worktree)
1205. 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 Decisions127## Key Design Decisions
46 128
47### 1. WorktreeStore Contains Mixed Local/Remote Worktrees129### 1. WorktreeStore Will Contain Mixed Local/Remote Worktrees
48 130
49The shared WorktreeStore can contain both local and remote worktrees simultaneously:131Eventually 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
54This works because **transport lives at the Worktree level**, not WorktreeStore level:135This 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
58When 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```rust140```rust
62impl WorktreeStore {141impl 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 Worktrees147### 2. Project Becomes a "View" Over Shared Worktrees
69 148
70Project tracks which worktrees it contains via `worktree_ids: HashSet<WorktreeId>`:
71
72```rust149```rust
73struct Project {150struct Project {
74 worktree_ids: HashSet<WorktreeId>,151 worktree_ids: HashSet<WorktreeId>,
75 worktree_store: Entity<WorktreeStore>, // Reference to shared store152 worktree_store: Entity<WorktreeStore>, // Reference to shared store
76 // ...
77}153}
78 154
79impl Project {155impl Project {
@@ -85,38 +161,28 @@ impl Project { · 5 unmodified lines
85}161}
86```162```
87 163
88Worktrees are **oblivious to Projects** - they don't track which Projects reference them. This keeps the relationship clean and unidirectional.164Worktrees are **oblivious to Projects** - they don't track which Projects reference them.
89 165
90### 3. Shared Stores vs Per-Workspace Stores166### 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 B169- `BufferStore` - Edits in Workspace A visible in Workspace B
94- `GitStore` - Git state is per-repository170- `GitStore` - Git state is per-repository
95- `LspStore` - One language server instance per worktree, shared across workspaces171- `LspStore` - One language server instance per worktree
96- `ImageStore` - Same as BufferStore172- `ImageStore` - Same as BufferStore
97- `BreakpointStore` - Breakpoints are on files173- `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 activities176- `DapStore` - Debugging sessions are focused activities
101- `TaskStore` - Tasks run in a specific workspace context177- `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
106Collaboration 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.181A worktree can only be in ONE shared Project at a time. Validate when sharing:
112
113
114When sharing, validate:
115```rust182```rust
116impl Project {183impl 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 project185 // 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 Clarification201## Key Files
136
137There are two different IDs to understand:
1381. **`RemoteWorktree.project_id`** - The upstream source ID (e.g., SSH server's project ID). This is for RPC with the server hosting the files.
1392. **`Project.client_state.remote_id`** - The collaboration ID when this Project is shared with others.
140
141These 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
145Worktrees 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
1541. 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
1632. Make MultiWorkspace global or per-window (TBD based on window model)
164
1653. 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
1721. Remove `WorktreeStoreState` enum
1732. Add explicit `add_local_worktree` and `add_remote_worktree` methods
1743. Update `find_or_create_worktree` to take connection info parameter
175
176### Phase 3: Refactor Project
177
1781. Add `worktree_ids: HashSet<WorktreeId>` field
1792. Change `worktree_store` from owned to shared reference
1803. Update all methods that iterate worktrees to filter by `worktree_ids`
1814. Move DapStore and TaskStore creation into Project (keep them per-workspace)
1825. Add references to shared stores (BufferStore, GitStore, etc.)
183
184### Phase 4: Refactor Store Creation
185
1861. Move BufferStore, GitStore, LspStore, ImageStore, BreakpointStore creation to MultiWorkspace
1872. Update these stores to work with the shared WorktreeStore
1883. Ensure stores handle worktrees being added/removed dynamically
189
190### Phase 5: Update Collaboration
191
1921. Add sharing validation (prevent double-sharing of worktrees)
1932. Update `share_project` to work with the new architecture
1943. Test collaboration scenarios with multiple workspaces
195
196### Phase 6: Update Consumers
197
198Many 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
2151. **Multi-workspace scenarios**: Create tests where two workspaces share worktrees
2162. **Edit propagation**: Verify edits in one workspace appear in another
2173. **Collaboration with shared worktrees**: Test sharing when worktrees overlap
2184. **Mixed local/remote**: Test WorktreeStore with both local and SSH worktrees
2195. **Worktree lifecycle**: Test closing workspaces and worktree cleanup
220
221## Open Questions for Implementation
222 202
2231. Should MultiWorkspace be global (one per app) or per-window?203- `crates/workspace/src/workspace.rs` - Contains `MultiWorkspace`, will contain `Workspace`
2242. How should we handle the transition for existing serialized workspaces?204- `crates/workspace/src/single_workspace.rs` - Stub module (created)
2253. 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## References208## 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 architecture211- [x] Create stub `single_workspace::Workspace` module (done)
231- Current Project::local() and Project::remote() constructors show store creation patterns212- [ ] 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
phase-2-multiworkspace-plan.md+0−1067
@@ -1,1067 +0,0 @@
1# Phase 2: MultiWorkspace Implementation Plan
2
3This document details Phase 2 of the MultiWorkspace Agent V2 Refactor. Phase 1 (GlobalThreadStore Infrastructure) is a prerequisite.
4
5## Overview
6
7Enable 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
111. Create `ThreadWorktrees` as a scoped lens into `Project` for panels
122. Implement `WorkspaceState` to store per-thread UI state (panes, panels)
133. Enable switching between thread contexts without losing state
144. 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
27The identity that ties a thread to workspace-level state:
28
29```rust
30// New file: crates/project/src/thread_worktrees.rs
31
32use gpui::{App, Context, Entity, EventEmitter, Task, WeakEntity};
33use collections::HashSet;
34use std::path::PathBuf;
35use anyhow::{Result, anyhow};
36
37/// Identifies a ThreadWorktrees group - glues thread identity to workspace identity
38#[derive(Clone, Hash, Eq, PartialEq, Debug)]
39pub struct ThreadWorktreesId {
40 /// None for "default" (no thread active)
41 pub session_id: Option<acp::SessionId>,
42 pub workspace_db_id: WorkspaceId,
43}
44
45impl 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.
67pub 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
78Modify `crates/project/src/project.rs`:
79
80```rust
81pub 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
94impl 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
143Panels 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
147Based on existing code analysis:
148
149```rust
150// crates/git_ui/src/git_panel.rs - Current
151pub 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
158pub 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
170impl 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)]
352pub 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
365impl EventEmitter<ThreadWorktreesEvent> for ThreadWorktrees {}
366
367impl 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
441Modify `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.
448pub 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
466impl 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
507pub 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
540impl 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
672impl 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
698pub 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
710impl 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
756pub struct GitPanel {
757 project: Entity<Project>,
758 active_repository: Option<Entity<Repository>>,
759 // ...
760}
761
762impl 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
789pub struct GitPanel {
790 thread_worktrees: Entity<ThreadWorktrees>,
791 active_repository: Option<Entity<Repository>>,
792 // ...
793}
794
795impl 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
823impl 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
842For each panel migrating from `Entity<Project>` to `Entity<ThreadWorktrees>`:
843
8441. [ ] Replace `project: Entity<Project>` field with `thread_worktrees: Entity<ThreadWorktrees>`
8452. [ ] Update constructor to take `Entity<ThreadWorktrees>` parameter
8463. [ ] Replace `cx.subscribe(&git_store, ...)` with `cx.subscribe(&thread_worktrees, ...)`
8474. [ ] Update event handlers to use `ThreadWorktreesEvent` variants
8485. [ ] Replace `self.project.read(cx).visible_worktrees(cx)` with `self.thread_worktrees.read(cx).visible_worktrees(cx)`
8496. [ ] Replace `self.project.read(cx).active_repository(cx)` with `self.thread_worktrees.read(cx).active_repository(cx)`
8507. [ ] Keep pass-through methods: `is_via_collab()`, `languages()`, `path_style()`, `fs()`
8518. [ ] Add `impl PanelFromThreadWorktrees for Panel` if needed
852
853---
854
855## 2.7 Feature-Flagged Actions
856
857All 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
864actions!(
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)]
877pub struct SwitchToThread {
878 pub session_id: String,
879}
880
881impl_actions!(workspace, [SwitchToThread, OpenThread, CloseThread]);
882```
883
884### Action Handlers
885
886```rust
887impl 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
909When "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
935This 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
9581. Create `crates/project/src/thread_worktrees.rs` with `ThreadWorktreesId` and `ThreadWorktrees` struct
9592. Add basic API methods (scoped worktree access, pass-throughs)
9603. Add `thread_worktrees` HashMap to `Project`
9614. Wire up module in `crates/project/src/project.rs`
962
963### Step 2: Event Filtering
9641. Implement `ThreadWorktreesEvent` enum
9652. Add subscriptions to git_store and worktree_store in `ThreadWorktrees::new`
9663. Implement `handle_git_store_event` and `handle_worktree_store_event`
967
968### Step 3: WorkspaceState
9691. Create `WorkspaceState` struct in workspace.rs
9702. Add `workspace_states` HashMap to `Workspace`
9713. Implement `switch_to_thread` method
9724. Add feature-flagged actions
973
974### Step 4: Panel Migration (one at a time)
9751. Start with `ProjectPanel` (simplest, most isolated)
9762. Then `GitPanel` (tests event filtering)
9773. Then remaining panels
978
979### Step 5: Integration Testing
9801. Test switching between threads
9812. Test panel state preservation
9823. Test cross-thread file navigation
983
984---
985
986## Testing Strategy
987
988### Unit Tests
989
990```rust
991#[gpui::test]
992async 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]
1025async 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]
1034async 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
10471. **Project remains singular** - One `Entity<Project>` per window holds all worktrees. ThreadWorktrees is a filtered lens, not a separate data store.
1048
10492. **ThreadWorktrees as panel API surface** - Provides type safety, centralized event filtering, and clear API boundary.
1050
10513. **Buffer store is shared** - Buffers represent files which are singular. Edits in shared worktrees are visible across all threads immediately.
1052
10534. **Center panes are not scoped** - Only panels are filtered. Users can open any file from any loaded worktree in editor tabs.
1054
10555. **Panel state preserved by design** - Each ThreadWorktrees has its own panel instances stored in WorkspaceState. Switching threads swaps which instances are displayed.
1056
10576. **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
10631. **Dock panel swapping mechanism** - How exactly do docks swap panel instances? May need new Dock API.
1064
10652. **Panel initialization timing** - Should panels be lazily initialized on first access or eagerly on thread switch?
1066
10673. **Serialization** - Should WorkspaceState be serialized per-thread for restore on restart? (Likely Phase 3)
pluggable-shaders-gpui-plan.md+0−408
@@ -1,408 +0,0 @@
1# Pluggable Shaders for GPUI
2
3## Overview
4
5This 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
26GPUI 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
331. **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
382. **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
433. **BladeRenderer** (`blade_renderer.rs:369-386`):
44 - Owns pipelines, instance buffers, atlas, textures
45 - `draw()` method iterates batches and dispatches to appropriate pipelines
46
474. **BladePipelines** (`blade_renderer.rs:140-150`):
48 - Struct holding all render pipelines
49 - Created at window initialization with shader source
50
515. **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```
58Window::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
70The 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.
111pub 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
134pub 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
145pub 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)]
156pub 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.
167pub 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
185impl 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
200impl 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)]
228struct 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
238unsafe 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
246const GLOW_SHADER: &str = r#"
247struct 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
257var<storage, read> b_glow_quads: array<GlowQuad>;
258
259struct GlowVarying {
260 @builtin(position) position: vec4<f32>,
261 @location(0) @interpolate(flat) color: vec4<f32>,
262 // ... more fields
263}
264
265@vertex
266fn 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
274fn fs_glow(input: GlowVarying) -> @location(0) vec4<f32> {
275 // ... glow effect calculation
276}
277"#;
278
279// 3. Register at app startup
280fn 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
300impl 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
3221. **[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
3282. **[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
3333. **[DONE] Extend App with registration API**
334 - Add `register_custom_shader()` method
335 - Store registry in App state (`custom_shader_registry` field)
336
3374. **[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
3425. **[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
3526. **[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
3587. **[DONE] Add Window painting API** (`crates/gpui/src/window.rs`)
359 - Add `paint_custom()` method
360
3618. **[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
3659. **[TODO] Add tests and examples**
366 - Unit tests for registration and painting
367 - Example custom shader (e.g., glow effect, noise pattern)
368
36910. **[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
3751. **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
3802. **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
3863. **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
3914. **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
3965. **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
threads-sidebar-plan.md+0−921
@@ -1,921 +0,0 @@
1# MultiWorkspace Agent V2 Refactor
2
3
4
5
6
7
8
9This 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
15Adapt 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
19Add Global wrapper pattern (similar to `HistoryManager`):
20
21```rust
22// In crates/agent/src/thread_store.rs
23struct GlobalThreadStore(Entity<ThreadStore>);
24impl Global for GlobalThreadStore {}
25
26impl ThreadStore {
27 pub fn init(cx: &mut App) { ... }
28 pub fn global(cx: &App) -> Entity<Self> { ... }
29}
30```
31
32Initialize 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
40pub 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
48pub 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
57Add ownership tracking to `ThreadStore`:
58
59```rust
60pub 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
69Key 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
78Update [`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
88Enable 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
92Add 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)]
97pub 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
103pub 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
110pub 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
131Panels 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
136pub struct ThreadWorktrees {
137 id: ThreadWorktreesId,
138 project: Entity<Project>,
139 worktree_ids: HashSet<WorktreeId>,
140}
141
142impl 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
216ThreadWorktrees re-emits filtered events so panels don't need to filter themselves:
217
218```rust
219pub enum ThreadWorktreesEvent {
220 ActiveRepositoryChanged(Option<Entity<Repository>>),
221 RepositoryUpdated(RepositoryId),
222 WorktreeAdded(WorktreeId),
223 WorktreeRemoved(WorktreeId),
224}
225
226impl 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
265pub struct GitPanel {
266 project: Entity<Project>,
267 // ...
268}
269
270// After: Panel holds ThreadWorktrees
271pub struct GitPanel {
272 thread_worktrees: Entity<ThreadWorktrees>,
273 // ...
274}
275
276// Before: Direct project access
277let repos = self.project.read(cx).visible_worktrees(cx);
278
279// After: Scoped access via ThreadWorktrees
280let repos = self.thread_worktrees.read(cx).visible_worktrees(cx);
281
282// Before: Subscribe to git_store directly
283cx.subscribe(&git_store, |this, _, event, cx| { ... });
284
285// After: Subscribe to ThreadWorktrees events (pre-filtered)
286cx.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
2991. **Type safety** - Panels can't accidentally access wrong scope; they only have `ThreadWorktrees`
3002. **Centralized filtering** - Event filtering happens once in `ThreadWorktrees`, not in every panel
3013. **Clear API boundary** - Easy to see which operations are scoped vs global
3024. **Testability** - Can create `ThreadWorktrees` with specific worktree sets for testing
303
304### 2.3 WorkspaceState per ThreadWorktrees
305
306Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs):
307
308```rust
309/// State for a specific ThreadWorktrees context
310struct 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
320pub 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
344When switching threads:
345
3461. **Save current state** - Store current center/panes in `workspace_states`
3472. **Update Project** - Set `active_thread_worktrees` to new ThreadWorktreesId
3483. **Load/create WorkspaceState** - Either restore existing or create new
3494. **Swap dock panels** - Update docks to show panels from new WorkspaceState
3505. **Update status bar** - Refresh to reflect new active worktrees
351```rust
352impl 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
386Each ThreadWorktrees gets its own panel instances:
387
388```rust
389impl 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
405Add 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
415Create 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
451Create new [`crates/agent_ui_v2/src/agents_sidebar.rs`](crates/agent_ui_v2/src/agents_sidebar.rs):
452
453```rust
454pub struct AgentsSidebar {
455 width: Pixels,
456 thread_history: Entity<AcpThreadHistory>, // Reuse existing
457 running_threads: Vec<RunningThread>,
458 focus_handle: FocusHandle,
459}
460
461struct 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
469impl 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
487Replace 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
491pub struct AgentPanel {
492 thread_view: Option<Entity<AcpThreadView>>,
493 thread_worktrees_id: ThreadWorktreesId,
494 // No history - that's in the sidebar now
495}
496
497impl 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
513Delete 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
521impl 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
539Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs) to add sidebar:
540
541```rust
542pub 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
552impl 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
568Add 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
574impl 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
593graph 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```
656Window (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```
674Thread A: folders {zed, cloud}
675Thread B: folders {cloud, zed.dev}
676
677Window 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
7081. **Thread-project association is explicit** - Users must explicitly associate threads with projects (UI in Phase 3). No automatic association on thread creation.
709
7102. **Existing threads have empty paths** - Backwards compatible: threads without `project_paths` are treated as available everywhere (no folder ownership conflicts).
711
7123. **Missing paths prompt reassociation** - If a thread's associated folders no longer exist on disk, prompt user to re-associate when opening.
713
7144. **Association scope** - When associating, all worktrees in the current project are associated.
715
7165. **Association UI entry points** (Phase 3):
717 - Thread context menu
718 - Sidebar action
719 - Auto-prompt on new thread creation
720
7216. **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
7267. **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
7288. **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
754Implementation:
755```rust
756impl 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
776When "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
782Rationale:
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
788Architecture:
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
811GPUI 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:**
8181. Remove `WorkspaceState` from `workspace_states` hashmap
8192. WorkspaceState is dropped, which drops panel instances
8203. Panels drop their `Entity<ThreadWorktrees>` references
8214. ThreadWorktrees is automatically cleaned up by GPUI (refcount → 0)
8225. Worktrees may stay in Project (if referenced by other threads) or get cleaned up similarly
823
824No 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
830Each 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
835No 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
869fn 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
888Global 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
894Sidebar 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:**
906Alongside the existing workspace serialization in the DB, keyed by `WorkspaceId`. The `SerializedWorkspace` struct (or a new sibling) would include sidebar state.
907
908```rust
909struct 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```
utility-pane-removal-plan.md+0−154
@@ -1,154 +0,0 @@
1# Utility Pane Removal Plan
2
3## Overview
4
5The 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
10According 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
85The `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
96Also 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
1061. Delete `crates/agent_ui_v2/src/agent_thread_pane.rs`
1072. 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
1191. Delete `crates/workspace/src/utility_pane.rs`
1202. 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
1253. 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()`
1364. 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
1431. Edit `crates/settings/src/settings_content/agent.rs`:
144 - Remove or update the "utility pane" dock setting documentation
145
146---
147
148## Testing
149
150After removal:
1511. Run `cargo check -p workspace -p agent_ui_v2`
1522. Run `./script/clippy`
1533. Verify the workspace renders correctly without utility panes
1544. Verify the agents panel still functions (without the thread pane popup)