sidebar: Add debug action to dump multi-workspace state (#52785)

Eric Holk created

Add a debug-only (`cfg(debug_assertions)`) action `DumpWorkspaceInfo`
that opens a read-only buffer with a dump of all workspace state. This
is useful for debugging the sidebar's view of workspaces and threads,
since the sidebar is currently the only way to see workspace-related
state.

For each workspace in the MultiWorkspace it shows:

- **Workspace DB ID** — for cross-referencing with persistence
- **All worktrees** with their paths, branches, and visibility
- **Whether each worktree is a git linked worktree** and where it links
to
- **Active agent thread** with title, session ID, status, and entry
count
- **Background agent threads** with the same detail
- **A warning** if the agent panel's workspace ID doesn't match the
workspace

Example output:
```
MultiWorkspace: 3 workspace(s)
Active workspace index: 1

--- Workspace 0 ---
Workspace DB ID: WorkspaceId(42)
Worktrees:
  - /Users/eric/repo/scratch3 [branch: refs/heads/scratch3] [linked worktree -> /Users/eric/repo/scratch]
Active thread: Git Worktree Path Consistency Check (session: 575b4349-...) [idle, 42 entries]

--- Workspace 1 (active) ---
Workspace DB ID: WorkspaceId(57)
Worktrees:
  - /Users/eric/repo/worktrees/zed/my-branch/zed [branch: refs/heads/my-branch] [linked worktree -> /Users/eric/repo/zed]
Active thread: Sidebar Not Displaying Git Worktree (session: 8f337c5c-...) [generating, 17 entries, awaiting confirmation]
Background threads (1):
  - Previous Investigation (session: abc12345-...) [idle, 83 entries]

--- Workspace 2 ---
Workspace DB ID: WorkspaceId(63)
Worktrees:
  - /Users/eric/repo/ex [branch: refs/heads/main]
Active thread: (none)
```

### Implementation

The action and handler live in the `sidebar` crate (close to what they
debug), following the same pattern as `language_tools` owning
`OpenLanguageServerLogs` and `debugger_tools` owning
`OpenDebugAdapterLogs`. The `zed` crate has only a one-line
registration.

Two small public accessors were added to `AgentPanel`:
- `workspace_id()` — exposes the panel's workspace ID for mismatch
detection
- `background_threads()` — exposes retained background conversation
views

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_panel.rs |   8 +
crates/sidebar/src/sidebar.rs      | 202 ++++++++++++++++++++++++++++++++
crates/zed/src/zed.rs              |   2 
3 files changed, 212 insertions(+)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -1909,6 +1909,14 @@ impl AgentPanel {
         }
     }
 
+    pub fn workspace_id(&self) -> Option<WorkspaceId> {
+        self.workspace_id
+    }
+
+    pub fn background_threads(&self) -> &HashMap<acp::SessionId, Entity<ConversationView>> {
+        &self.background_threads
+    }
+
     pub fn active_conversation_view(&self) -> Option<&Entity<ConversationView>> {
         match &self.active_view {
             ActiveView::AgentThread { conversation_view } => Some(conversation_view),

crates/sidebar/src/sidebar.rs 🔗

@@ -68,6 +68,14 @@ gpui::actions!(
     ]
 );
 
+gpui::actions!(
+    dev,
+    [
+        /// Dumps multi-workspace state (projects, worktrees, active threads) into a new buffer.
+        DumpWorkspaceInfo,
+    ]
+);
+
 const DEFAULT_WIDTH: Pixels = px(300.0);
 const MIN_WIDTH: Pixels = px(200.0);
 const MAX_WIDTH: Pixels = px(800.0);
@@ -3448,3 +3456,197 @@ fn all_thread_infos_for_workspace(
 
     Some(threads).into_iter().flatten()
 }
+
+pub fn dump_workspace_info(
+    workspace: &mut Workspace,
+    _: &DumpWorkspaceInfo,
+    window: &mut gpui::Window,
+    cx: &mut gpui::Context<Workspace>,
+) {
+    use std::fmt::Write;
+
+    let mut output = String::new();
+    let this_entity = cx.entity();
+
+    let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade());
+    let workspaces: Vec<gpui::Entity<Workspace>> = match &multi_workspace {
+        Some(mw) => mw.read(cx).workspaces().to_vec(),
+        None => vec![this_entity.clone()],
+    };
+    let active_index = multi_workspace
+        .as_ref()
+        .map(|mw| mw.read(cx).active_workspace_index());
+
+    writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
+    if let Some(index) = active_index {
+        writeln!(output, "Active workspace index: {index}").ok();
+    }
+    writeln!(output).ok();
+
+    for (index, ws) in workspaces.iter().enumerate() {
+        let is_active = active_index == Some(index);
+        writeln!(
+            output,
+            "--- Workspace {index}{} ---",
+            if is_active { " (active)" } else { "" }
+        )
+        .ok();
+
+        // The action handler is already inside an update on `this_entity`,
+        // so we must avoid a nested read/update on that same entity.
+        if *ws == this_entity {
+            dump_single_workspace(workspace, &mut output, cx);
+        } else {
+            ws.read_with(cx, |ws, cx| {
+                dump_single_workspace(ws, &mut output, cx);
+            });
+        }
+    }
+
+    let project = workspace.project().clone();
+    cx.spawn_in(window, async move |_this, cx| {
+        let buffer = project
+            .update(cx, |project, cx| project.create_buffer(None, false, cx))
+            .await?;
+
+        buffer.update(cx, |buffer, cx| {
+            buffer.set_text(output, cx);
+        });
+
+        let buffer = cx.new(|cx| {
+            editor::MultiBuffer::singleton(buffer, cx).with_title("Workspace Info".into())
+        });
+
+        _this.update_in(cx, |workspace, window, cx| {
+            workspace.add_item_to_active_pane(
+                Box::new(cx.new(|cx| {
+                    let mut editor =
+                        editor::Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
+                    editor.set_read_only(true);
+                    editor.set_should_serialize(false, cx);
+                    editor.set_breadcrumb_header("Workspace Info".into());
+                    editor
+                })),
+                None,
+                true,
+                window,
+                cx,
+            );
+        })
+    })
+    .detach_and_log_err(cx);
+}
+
+fn dump_single_workspace(workspace: &Workspace, output: &mut String, cx: &gpui::App) {
+    use std::fmt::Write;
+
+    let workspace_db_id = workspace.database_id();
+    match workspace_db_id {
+        Some(id) => writeln!(output, "Workspace DB ID: {id:?}").ok(),
+        None => writeln!(output, "Workspace DB ID: (none)").ok(),
+    };
+
+    let project = workspace.project().read(cx);
+
+    let repos: Vec<_> = project
+        .repositories(cx)
+        .values()
+        .map(|repo| repo.read(cx).snapshot())
+        .collect();
+
+    writeln!(output, "Worktrees:").ok();
+    for worktree in project.worktrees(cx) {
+        let worktree = worktree.read(cx);
+        let abs_path = worktree.abs_path();
+        let visible = worktree.is_visible();
+
+        let repo_info = repos
+            .iter()
+            .find(|snapshot| abs_path.starts_with(&*snapshot.work_directory_abs_path));
+
+        let is_linked = repo_info.map(|s| s.is_linked_worktree()).unwrap_or(false);
+        let original_repo_path = repo_info.map(|s| &s.original_repo_abs_path);
+        let branch = repo_info.and_then(|s| s.branch.as_ref().map(|b| b.ref_name.clone()));
+
+        write!(output, "  - {}", abs_path.display()).ok();
+        if !visible {
+            write!(output, " (hidden)").ok();
+        }
+        if let Some(branch) = &branch {
+            write!(output, " [branch: {branch}]").ok();
+        }
+        if is_linked {
+            if let Some(original) = original_repo_path {
+                write!(output, " [linked worktree -> {}]", original.display()).ok();
+            } else {
+                write!(output, " [linked worktree]").ok();
+            }
+        }
+        writeln!(output).ok();
+    }
+
+    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+        let panel = panel.read(cx);
+
+        let panel_workspace_id = panel.workspace_id();
+        if panel_workspace_id != workspace_db_id {
+            writeln!(
+                output,
+                "  \u{26a0} workspace ID mismatch! panel has {panel_workspace_id:?}, workspace has {workspace_db_id:?}"
+            )
+            .ok();
+        }
+
+        if let Some(thread) = panel.active_agent_thread(cx) {
+            let thread = thread.read(cx);
+            let title = thread.title().unwrap_or_else(|| "(untitled)".into());
+            let session_id = thread.session_id();
+            let status = match thread.status() {
+                ThreadStatus::Idle => "idle",
+                ThreadStatus::Generating => "generating",
+            };
+            let entry_count = thread.entries().len();
+            write!(output, "Active thread: {title} (session: {session_id})").ok();
+            write!(output, " [{status}, {entry_count} entries").ok();
+            if thread.is_waiting_for_confirmation() {
+                write!(output, ", awaiting confirmation").ok();
+            }
+            writeln!(output, "]").ok();
+        } else {
+            writeln!(output, "Active thread: (none)").ok();
+        }
+
+        let background_threads = panel.background_threads();
+        if !background_threads.is_empty() {
+            writeln!(
+                output,
+                "Background threads ({}): ",
+                background_threads.len()
+            )
+            .ok();
+            for (session_id, conversation_view) in background_threads {
+                if let Some(thread_view) = conversation_view.read(cx).root_thread(cx) {
+                    let thread = thread_view.read(cx).thread.read(cx);
+                    let title = thread.title().unwrap_or_else(|| "(untitled)".into());
+                    let status = match thread.status() {
+                        ThreadStatus::Idle => "idle",
+                        ThreadStatus::Generating => "generating",
+                    };
+                    let entry_count = thread.entries().len();
+                    write!(output, "  - {title} (session: {session_id})").ok();
+                    write!(output, " [{status}, {entry_count} entries").ok();
+                    if thread.is_waiting_for_confirmation() {
+                        write!(output, ", awaiting confirmation").ok();
+                    }
+                    writeln!(output, "]").ok();
+                } else {
+                    writeln!(output, "  - (not connected) (session: {session_id})").ok();
+                }
+            }
+        }
+    } else {
+        writeln!(output, "Agent panel: not loaded").ok();
+    }
+
+    writeln!(output).ok();
+}

crates/zed/src/zed.rs 🔗

@@ -1202,6 +1202,8 @@ fn register_actions(
             }
         });
     }
+
+    workspace.register_action(sidebar::dump_workspace_info);
 }
 
 fn initialize_pane(