docs: Clean up old plan document (#51926)

## Context

Shouldn't have been committed in the first place.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-03-19 12:54:45 +01:00 committed by GitHub
parent 99d51a4f3c
commit 6758ac3590
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,580 +0,0 @@
# Plan: Show ACP Threads in the Sidebar (Revised)
## Problem
The sidebar currently only shows **Zed-native agent threads** (from `ThreadStore`/`ThreadsDatabase`). ACP threads (Claude Code, Codex, Gemini, etc.) are invisible in the sidebar once they're no longer live.
## Root Cause
`ThreadStore` and `ThreadsDatabase` only persist metadata for native threads. When `rebuild_contents` populates the sidebar, it reads from `ThreadStore` for historical threads and overlays live info from the `AgentPanel` — but non-native threads never get written to `ThreadStore`, so once they stop being live, they disappear.
## Solution Overview (Revised)
**Key change from the original plan:** We completely remove the sidebar's dependency on `ThreadStore`. Instead, the `Sidebar` itself owns a **single, unified persistence layer** — a new `SidebarDb` domain stored in the workspace DB — that tracks metadata for _all_ thread types (native and ACP). The sidebar becomes the single source of truth for what threads appear in the list.
### Why Remove the ThreadStore Dependency?
1. **Single responsibility** — The sidebar is the only consumer of "which threads to show in the list." Having it depend on `ThreadStore` (which exists primarily for native agent save/load) creates an indirect coupling that makes ACP integration awkward.
2. **No merge logic** — The original plan required merging native `ThreadStore` data with a separate `AcpThreadMetadataDb` in `ThreadStore::reload`. By moving all sidebar metadata into one place, there's nothing to merge.
3. **Simpler data flow** — Writers (native agent, ACP connections) push metadata to the sidebar DB. The sidebar reads from one table. No cross-crate coordination needed.
4. **ThreadStore stays focused**`ThreadStore` continues to manage native thread blob storage (save/load message data) without being polluted with sidebar display concerns.
### Architecture
```
┌─────────────────────┐ ┌─────────────────────────┐
│ NativeAgent │ │ ACP Connections │
│ (on save_thread) │ │ (on create/update/list) │
└──────────┬──────────┘ └──────────┬──────────────┘
│ │
│ save_sidebar_thread() │
└──────────┬─────────────────┘
┌───────────────────┐
│ SidebarDb │
│ (workspace DB) │
│ sidebar_threads │
└────────┬──────────┘
┌───────────────────┐
│ Sidebar │
│ rebuild_contents │
└───────────────────┘
```
---
## Step 1: Create `SidebarDb` Domain in `sidebar.rs`
**File:** `crates/agent_ui/src/sidebar.rs`
Add a `SidebarDb` domain using `db::static_connection!`, co-located in the sidebar module (or a small `persistence` submodule within `sidebar.rs` if it helps organization, but keeping it in the same file is fine for now).
### Schema
```rust
use db::{
sqlez::{
bindable::Column, domain::Domain, statement::Statement,
thread_safe_connection::ThreadSafeConnection,
},
sqlez_macros::sql,
};
/// Lightweight metadata for any thread (native or ACP), enough to populate
/// the sidebar list and route to the correct load path when clicked.
#[derive(Debug, Clone)]
pub struct SidebarThreadRow {
pub session_id: acp::SessionId,
/// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents.
pub agent_name: Option<String>,
pub title: SharedString,
pub updated_at: DateTime<Utc>,
pub created_at: Option<DateTime<Utc>>,
pub folder_paths: PathList,
}
pub struct SidebarDb(ThreadSafeConnection);
impl Domain for SidebarDb {
const NAME: &str = stringify!(SidebarDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS sidebar_threads(
session_id TEXT PRIMARY KEY,
agent_name TEXT,
title TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_at TEXT,
folder_paths TEXT,
folder_paths_order TEXT
) STRICT;
)];
}
db::static_connection!(SIDEBAR_DB, SidebarDb, []);
```
### CRUD Methods
```rust
impl SidebarDb {
/// Upsert metadata for a thread (native or ACP).
pub async fn save(&self, row: &SidebarThreadRow) -> Result<()> {
let id = row.session_id.0.clone();
let agent_name = row.agent_name.clone();
let title = row.title.to_string();
let updated_at = row.updated_at.to_rfc3339();
let created_at = row.created_at.map(|dt| dt.to_rfc3339());
let serialized = row.folder_paths.serialize();
let (fp, fpo) = if row.folder_paths.is_empty() {
(None, None)
} else {
(Some(serialized.paths), Some(serialized.order))
};
self.write(move |conn| {
let mut stmt = Statement::prepare(
conn,
"INSERT INTO sidebar_threads(session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(session_id) DO UPDATE SET
agent_name = excluded.agent_name,
title = excluded.title,
updated_at = excluded.updated_at,
folder_paths = excluded.folder_paths,
folder_paths_order = excluded.folder_paths_order",
)?;
let mut i = stmt.bind(&id, 1)?;
i = stmt.bind(&agent_name, i)?;
i = stmt.bind(&title, i)?;
i = stmt.bind(&updated_at, i)?;
i = stmt.bind(&created_at, i)?;
i = stmt.bind(&fp, i)?;
stmt.bind(&fpo, i)?;
stmt.exec()
})
.await
}
/// List all sidebar thread metadata, ordered by updated_at descending.
pub fn list(&self) -> Result<Vec<SidebarThreadRow>> {
self.select::<SidebarThreadRow>(
"SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
FROM sidebar_threads
ORDER BY updated_at DESC"
)?(())
}
/// List threads for a specific folder path set.
pub fn list_for_paths(&self, paths: &PathList) -> Result<Vec<SidebarThreadRow>> {
let serialized = paths.serialize();
self.select_bound::<String, SidebarThreadRow>(sql!(
SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
FROM sidebar_threads
WHERE folder_paths = ?
ORDER BY updated_at DESC
))?(serialized.paths)
}
/// Look up a single thread by session ID.
pub fn get(&self, session_id: &acp::SessionId) -> Result<Option<SidebarThreadRow>> {
let id = session_id.0.clone();
self.select_row_bound::<Arc<str>, SidebarThreadRow>(sql!(
SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order
FROM sidebar_threads
WHERE session_id = ?
))?(id)
}
/// Return the total number of rows in the table.
pub fn count(&self) -> Result<usize> {
let count: (i32, i32) = self.select_row(sql!(
SELECT COUNT(*) FROM sidebar_threads
))?(())?.unwrap_or_default();
Ok(count.0 as usize)
}
/// Delete metadata for a single thread.
pub async fn delete(&self, session_id: acp::SessionId) -> Result<()> {
let id = session_id.0;
self.write(move |conn| {
let mut stmt = Statement::prepare(
conn,
"DELETE FROM sidebar_threads WHERE session_id = ?",
)?;
stmt.bind(&id, 1)?;
stmt.exec()
})
.await
}
/// Delete all thread metadata.
pub async fn delete_all(&self) -> Result<()> {
self.write(move |conn| {
let mut stmt = Statement::prepare(
conn,
"DELETE FROM sidebar_threads",
)?;
stmt.exec()
})
.await
}
}
```
### `Column` Implementation
```rust
impl Column for SidebarThreadRow {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (id, next): (Arc<str>, i32) = Column::column(statement, start_index)?;
let (agent_name, next): (Option<String>, i32) = Column::column(statement, next)?;
let (title, next): (String, i32) = Column::column(statement, next)?;
let (updated_at_str, next): (String, i32) = Column::column(statement, next)?;
let (created_at_str, next): (Option<String>, i32) = Column::column(statement, next)?;
let (folder_paths_str, next): (Option<String>, i32) = Column::column(statement, next)?;
let (folder_paths_order_str, next): (Option<String>, i32) = Column::column(statement, next)?;
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc);
let created_at = created_at_str
.as_deref()
.map(DateTime::parse_from_rfc3339)
.transpose()?
.map(|dt| dt.with_timezone(&Utc));
let folder_paths = folder_paths_str
.map(|paths| {
PathList::deserialize(&util::path_list::SerializedPathList {
paths,
order: folder_paths_order_str.unwrap_or_default(),
})
})
.unwrap_or_default();
Ok((
SidebarThreadRow {
session_id: acp::SessionId::new(id),
agent_name,
title: title.into(),
updated_at,
created_at,
folder_paths,
},
next,
))
}
}
```
**Key points:**
- `SIDEBAR_DB` is a `LazyLock` static — initialized on first use, no manual connection management.
- The `agent_name` column is `NULL` for native Zed threads and a string like `"claude-code"` for ACP agents. This replaces the `agent_type` field from the original plan.
- The DB file lives alongside other `static_connection!` databases.
- `ThreadsDatabase` and `ThreadStore` are **completely unchanged** by this step.
---
## Step 2: Replace `ThreadStore` Reads in `rebuild_contents` with `SidebarDb` Reads
**File:** `crates/agent_ui/src/sidebar.rs`
### Remove `ThreadStore` Dependency
1. **Remove** `ThreadStore::global(cx)` and `ThreadStore::try_global(cx)` from `Sidebar::new` and `rebuild_contents`.
2. **Remove** the `cx.observe_in(&thread_store, ...)` subscription that triggers `update_entries` when `ThreadStore` changes.
3. **Replace** `thread_store.read(cx).threads_for_paths(&path_list)` calls with `SIDEBAR_DB.list_for_paths(&path_list)` (or read all rows once at the top of `rebuild_contents` and index them in memory, which is simpler and avoids repeated DB calls).
### New Data Flow in `rebuild_contents`
```rust
fn rebuild_contents(&mut self, cx: &App) {
// ... existing workspace iteration setup ...
// Read ALL sidebar thread metadata once, index by folder_paths.
let all_sidebar_threads = SIDEBAR_DB.list().unwrap_or_default();
let mut threads_by_paths: HashMap<PathList, Vec<SidebarThreadRow>> = HashMap::new();
for row in all_sidebar_threads {
threads_by_paths
.entry(row.folder_paths.clone())
.or_default()
.push(row);
}
for (ws_index, workspace) in workspaces.iter().enumerate() {
// ... existing absorbed-workspace logic ...
let path_list = workspace_path_list(workspace, cx);
if should_load_threads {
let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
// Read from SidebarDb instead of ThreadStore
if let Some(rows) = threads_by_paths.get(&path_list) {
for row in rows {
seen_session_ids.insert(row.session_id.clone());
let (agent, icon) = match &row.agent_name {
None => (Agent::NativeAgent, IconName::ZedAgent),
Some(name) => (
Agent::Custom { name: name.clone().into() },
IconName::ZedAgent, // placeholder, resolved in Step 5
),
};
threads.push(ThreadEntry {
agent,
session_info: AgentSessionInfo {
session_id: row.session_id.clone(),
cwd: None,
title: Some(row.title.clone()),
updated_at: Some(row.updated_at),
created_at: row.created_at,
meta: None,
},
icon,
icon_from_external_svg: None,
status: AgentThreadStatus::default(),
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
worktree_name: None,
worktree_highlight_positions: Vec::new(),
diff_stats: DiffStats::default(),
});
}
}
// ... existing linked git worktree logic, also reading from threads_by_paths ...
// ... existing live thread overlay logic (unchanged) ...
}
}
}
```
### What Changes
- `rebuild_contents` reads from `SIDEBAR_DB` instead of `ThreadStore`.
- The `ThreadEntry.agent` field now carries `Agent::Custom { name }` for ACP threads, enabling correct routing in `activate_thread`.
- The live thread overlay logic (from `all_thread_infos_for_workspace`) is **unchanged** — it still reads from `AgentPanel` to get real-time status of running threads.
### What Stays the Same
- The entire workspace/absorbed-workspace/git-worktree structure.
- The live thread overlay pass.
- The notification tracking logic.
- The search/filter logic.
---
## Step 3: Write Native Thread Metadata to `SidebarDb`
**File:** `crates/agent_ui/src/sidebar.rs` and/or `crates/agent_ui/src/agent_panel.rs`
When a native thread is saved (after conversation, on title update, etc.), we also write its metadata to `SidebarDb`. There are two approaches:
### Option A: Subscribe to `ThreadStore` Changes (Recommended)
Keep a one-directional sync: when `ThreadStore` finishes a `save_thread` or `reload`, the sidebar syncs the metadata to `SidebarDb`. This can be done in the sidebar's workspace subscription or by observing `ThreadStore` changes purely for the purpose of syncing (not for reading).
```rust
// In Sidebar::subscribe_to_workspace or a dedicated sync method:
fn sync_native_threads_to_sidebar_db(&self, cx: &App) {
if let Some(thread_store) = ThreadStore::try_global(cx) {
let entries: Vec<_> = thread_store.read(cx).entries().collect();
cx.background_spawn(async move {
for meta in entries {
SIDEBAR_DB.save(&SidebarThreadRow {
session_id: meta.id,
agent_name: None, // native
title: meta.title,
updated_at: meta.updated_at,
created_at: meta.created_at,
folder_paths: meta.folder_paths,
}).await.log_err();
}
}).detach();
}
}
```
### Option B: Write at the Point of Save
In `AgentPanel` or wherever `thread_store.save_thread()` is called, also call `SIDEBAR_DB.save(...)`. This is more direct but requires touching more call sites.
**Recommendation:** Option A is simpler for the initial implementation. We observe `ThreadStore` changes, diff against `SidebarDb`, and sync. Later, if we want to remove `ThreadStore` entirely from the write path for native threads, we can switch to Option B.
---
## Step 4: Write ACP Thread Metadata to `SidebarDb`
**File:** `crates/agent_ui/src/connection_view.rs` (or `agent_panel.rs`)
When ACP sessions are created, updated, or listed, write metadata directly to `SidebarDb`:
- **On new session creation:** After `connection.new_session()` returns the `AcpThread`, call `SIDEBAR_DB.save(...)`.
- **On title update:** ACP threads receive title updates via `SessionInfoUpdate`. When these come in, call `SIDEBAR_DB.save(...)` with the new title and updated timestamp.
- **On session list refresh:** When `AgentSessionList::list_sessions` returns for an ACP agent, bulk-sync the metadata into `SidebarDb`.
After any write, call `cx.notify()` on the `Sidebar` entity (or use a channel/event) to trigger a `rebuild_contents`.
### Triggering Sidebar Refresh
Since the sidebar no longer observes `ThreadStore`, we need a mechanism to trigger `rebuild_contents` after DB writes. Options:
1. **Emit an event from `AgentPanel`** — The sidebar already subscribes to `AgentPanelEvent`. Add a new variant like `AgentPanelEvent::ThreadMetadataChanged` and emit it after saving to `SidebarDb`.
2. **Use `cx.notify()` directly** — If the save happens within a `Sidebar` method, just call `self.update_entries(cx)`.
3. **Observe a lightweight signal entity** — A simple `Entity<()>` that gets notified after DB writes.
**Recommendation:** Option 1 (emit from `AgentPanel`) is cleanest since the sidebar already subscribes to panel events.
---
## Step 5: Handle Agent Icon Resolution for ACP Threads
**File:** `crates/agent_ui/src/sidebar.rs`
For ACP threads in the sidebar, we need the correct agent icon. The `agent_name` string stored in `SidebarDb` maps to an agent in the `AgentServerStore`, which has icon info.
In `rebuild_contents`, after building the initial thread list from `SidebarDb`, resolve icons for ACP threads:
```rust
// For ACP threads, look up the icon from the agent server store
if let Some(name) = &row.agent_name {
if let Some(agent_server_store) = /* get from workspace */ {
// resolve icon from agent_server_store using name
}
}
```
---
## Step 6: Handle Delete Operations Correctly
**File:** `crates/agent_ui/src/sidebar.rs`
When the user deletes a thread from the sidebar:
- **All threads** → Delete from `SidebarDb` via `SIDEBAR_DB.delete(session_id)`.
- **Native threads**_Also_ delete from `ThreadStore`/`ThreadsDatabase` (to clean up the blob data).
- **ACP threads** → Optionally notify the ACP server via `AgentSessionList::delete_session`.
The `agent_name` field on `SidebarThreadRow` (or the `Agent` enum on `ThreadEntry`) tells us which path to take.
When the user clears all history:
```rust
// Delete all sidebar metadata
SIDEBAR_DB.delete_all().await?;
// Also clear native thread blobs
thread_store.delete_threads(cx);
// Optionally notify ACP servers
```
---
## Step 7: Handle `activate_thread` Routing
**File:** `crates/agent_ui/src/sidebar.rs`, `crates/agent_ui/src/agent_panel.rs`
In `activate_thread`, branch on the `Agent` variant:
- `Agent::NativeAgent` → Call `panel.load_agent_thread(Agent::NativeAgent, session_id, ...)` (current behavior).
- `Agent::Custom { name }` → Call `panel.load_agent_thread(Agent::Custom { name }, session_id, ...)` so it routes to the correct `AgentConnection::load_session`.
This is already partially set up — `activate_thread` takes an `Agent` parameter. The key change is that `ThreadEntry` now carries the correct `Agent` variant based on `SidebarThreadRow.agent_name`.
---
## Step 8: Handle `activate_archived_thread` Without ThreadStore
**File:** `crates/agent_ui/src/sidebar.rs`
Currently, `activate_archived_thread` looks up `saved_path_list` from `ThreadStore`:
```rust
let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
thread_store
.read(cx)
.thread_from_session_id(&session_info.session_id)
.map(|thread| thread.folder_paths.clone())
});
```
Replace this with a targeted `SidebarDb::get` lookup (single-row SELECT, no full table scan):
```rust
let saved_path_list = SIDEBAR_DB
.get(&session_info.session_id)
.ok()
.flatten()
.map(|row| row.folder_paths);
```
---
## Step 9: Error Handling for Offline Agents
When an ACP thread is clicked but the agent server is not running:
- Show a toast/notification explaining the agent is offline.
- Keep the metadata in the sidebar (don't remove it).
- Optionally offer to start the agent server.
---
## Step 10: Migration — Backfill Existing Native Threads
On first launch after this change, the `SidebarDb` will be empty while `ThreadsDatabase` has existing native threads. We need a one-time backfill:
```rust
// In Sidebar::new or a dedicated init method:
fn backfill_native_threads_if_needed(cx: &App) {
if SIDEBAR_DB.count() > 0 {
return; // Already populated
}
if let Some(thread_store) = ThreadStore::try_global(cx) {
let entries: Vec<_> = thread_store.read(cx).entries().collect();
cx.background_spawn(async move {
for meta in entries {
SIDEBAR_DB.save(&SidebarThreadRow {
session_id: meta.id,
agent_name: None,
title: meta.title,
updated_at: meta.updated_at,
created_at: meta.created_at,
folder_paths: meta.folder_paths,
}).await.log_err();
}
}).detach();
}
}
```
---
## Summary of Files to Change
| File | Changes |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `crates/agent_ui/Cargo.toml` | Add `db.workspace = true`, `sqlez.workspace = true`, `sqlez_macros.workspace = true`, `chrono.workspace = true` dependencies |
| `crates/agent_ui/src/sidebar.rs` | **Main changes.** Add `SidebarDb` domain + `SIDEBAR_DB` static + `SidebarThreadRow`. Replace all `ThreadStore` reads in `rebuild_contents` with `SidebarDb` reads. Update `activate_archived_thread`. Add native thread sync logic. Add backfill on first run. |
| `crates/agent_ui/src/agent_panel.rs` | Emit `AgentPanelEvent::ThreadMetadataChanged` after thread saves. Potentially write ACP metadata to `SidebarDb` here. |
| `crates/agent_ui/src/connection_view.rs` | Write ACP metadata to `SidebarDb` on session creation, title updates, and session list refreshes. |
## What Is NOT Changed
| File / Area | Why |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `threads` table schema | No migration needed — native blob persistence is completely untouched |
| `ThreadsDatabase` methods | `save_thread_sync`, `load_thread`, `list_threads`, `delete_thread`, `delete_threads` — all unchanged |
| `ThreadStore` struct/methods | Stays exactly as-is. It's still used for native thread blob save/load. The sidebar just no longer reads from it for display. |
| `NativeAgent::load_thread` / `open_thread` | These deserialize `DbThread` blobs — completely unaffected |
| `crates/acp_thread/` | No new persistence module needed there (unlike the original plan) |
| `crates/agent/src/db.rs` | `DbThreadMetadata` is unchanged — no `agent_type` field added |
## Execution Order
1. **SidebarDb domain** (Step 1) — Create `SidebarDb`, `SidebarThreadRow`, `SIDEBAR_DB` static, CRUD methods in `sidebar.rs`.
2. **Replace reads** (Step 2) — Swap `ThreadStore` reads in `rebuild_contents` for `SidebarDb` reads.
3. **Native write path** (Step 3) — Sync native thread metadata from `ThreadStore` into `SidebarDb`.
4. **ACP write path** (Step 4) — Write ACP thread metadata to `SidebarDb` from connection views.
5. **Icon resolution** (Step 5) — Resolve ACP agent icons in the sidebar.
6. **Delete path** (Step 6) — Route deletes to `SidebarDb` + native blob cleanup + ACP server notification.
7. **Activate routing** (Step 7) — Ensure `activate_thread` routes correctly based on `Agent` variant.
8. **Archive fix** (Step 8) — Update `activate_archived_thread` to use `SidebarDb`.
9. **Migration** (Step 10) — Backfill existing native threads on first run.
10. **Polish** (Step 9) — Error handling for offline agents.
## Key Differences from Original Plan
| Aspect | Original Plan | Revised Plan |
| ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| **Where ACP metadata lives** | New `AcpThreadMetadataDb` in `crates/acp_thread/` | `SidebarDb` in `crates/agent_ui/src/sidebar.rs` |
| **Where sidebar reads from** | `ThreadStore` (which merges native + ACP) | `SidebarDb` directly (single source) |
| **ThreadStore changes** | Added `agent_type` to `DbThreadMetadata`, merge logic in `reload`, new save/delete methods | **None** — ThreadStore is untouched |
| **`crates/agent/src/db.rs` changes** | Added `agent_type: Option<String>` to `DbThreadMetadata` | **None** |
| **Merge complexity** | Two data sources merged in `ThreadStore::reload` | No merge — one table, one read |
| **Crate dependencies** | `acp_thread` gains `db` dependency | `agent_ui` gains `db` dependency (more natural — it's a UI persistence concern) |