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?
- 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. - No merge logic — The original plan required merging native
ThreadStoredata with a separateAcpThreadMetadataDbinThreadStore::reload. By moving all sidebar metadata into one place, there's nothing to merge. - 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.
- ThreadStore stays focused —
ThreadStorecontinues 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
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
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
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_DBis aLazyLockstatic — initialized on first use, no manual connection management.- The
agent_namecolumn isNULLfor native Zed threads and a string like"claude-code"for ACP agents. This replaces theagent_typefield from the original plan. - The DB file lives alongside other
static_connection!databases. ThreadsDatabaseandThreadStoreare 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
- Remove
ThreadStore::global(cx)andThreadStore::try_global(cx)fromSidebar::newandrebuild_contents. - Remove the
cx.observe_in(&thread_store, ...)subscription that triggersupdate_entrieswhenThreadStorechanges. - Replace
thread_store.read(cx).threads_for_paths(&path_list)calls withSIDEBAR_DB.list_for_paths(&path_list)(or read all rows once at the top ofrebuild_contentsand index them in memory, which is simpler and avoids repeated DB calls).
New Data Flow in rebuild_contents
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_contentsreads fromSIDEBAR_DBinstead ofThreadStore.- The
ThreadEntry.agentfield now carriesAgent::Custom { name }for ACP threads, enabling correct routing inactivate_thread. - The live thread overlay logic (from
all_thread_infos_for_workspace) is unchanged — it still reads fromAgentPanelto 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).
// 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 theAcpThread, callSIDEBAR_DB.save(...). - On title update: ACP threads receive title updates via
SessionInfoUpdate. When these come in, callSIDEBAR_DB.save(...)with the new title and updated timestamp. - On session list refresh: When
AgentSessionList::list_sessionsreturns for an ACP agent, bulk-sync the metadata intoSidebarDb.
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:
- Emit an event from
AgentPanel— The sidebar already subscribes toAgentPanelEvent. Add a new variant likeAgentPanelEvent::ThreadMetadataChangedand emit it after saving toSidebarDb. - Use
cx.notify()directly — If the save happens within aSidebarmethod, just callself.update_entries(cx). - 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:
// 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
SidebarDbviaSIDEBAR_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:
// 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→ Callpanel.load_agent_thread(Agent::NativeAgent, session_id, ...)(current behavior).Agent::Custom { name }→ Callpanel.load_agent_thread(Agent::Custom { name }, session_id, ...)so it routes to the correctAgentConnection::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:
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):
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:
// 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
- SidebarDb domain (Step 1) — Create
SidebarDb,SidebarThreadRow,SIDEBAR_DBstatic, CRUD methods insidebar.rs. - Replace reads (Step 2) — Swap
ThreadStorereads inrebuild_contentsforSidebarDbreads. - Native write path (Step 3) — Sync native thread metadata from
ThreadStoreintoSidebarDb. - ACP write path (Step 4) — Write ACP thread metadata to
SidebarDbfrom connection views. - Icon resolution (Step 5) — Resolve ACP agent icons in the sidebar.
- Delete path (Step 6) — Route deletes to
SidebarDb+ native blob cleanup + ACP server notification. - Activate routing (Step 7) — Ensure
activate_threadroutes correctly based onAgentvariant. - Archive fix (Step 8) — Update
activate_archived_threadto useSidebarDb. - Migration (Step 10) — Backfill existing native threads on first run.
- 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) |