sidebar: Add a new thread draft system (#53574)

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Danilo Leal 2026-04-10 20:19:55 -03:00 committed by GitHub
parent f4477249f2
commit 86f55495c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1231 additions and 555 deletions

View file

@ -56,7 +56,7 @@ use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable,
DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, Global,
KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
pulsating_between,
};
@ -204,21 +204,12 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
}
})
.register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
.register_action(|workspace, _action: &NewExternalAgentThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
let initial_content = panel.take_active_draft_initial_content(cx);
panel.external_thread(
action.agent.clone(),
None,
None,
None,
initial_content,
true,
window,
cx,
)
let id = panel.create_draft(window, cx);
panel.activate_draft(id, true, window, cx);
});
}
})
@ -602,6 +593,25 @@ fn build_conflicted_files_resolution_prompt(
content
}
/// Unique identifier for a sidebar draft thread. Not persisted across restarts.
/// IDs are globally unique across all AgentPanel instances within the same app.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DraftId(pub usize);
#[derive(Default)]
struct DraftIdCounter(usize);
impl Global for DraftIdCounter {}
impl DraftId {
fn next(cx: &mut App) -> Self {
let counter = cx.default_global::<DraftIdCounter>();
let id = counter.0;
counter.0 += 1;
Self(id)
}
}
enum ActiveView {
Uninitialized,
AgentThread {
@ -803,6 +813,7 @@ pub struct AgentPanel {
active_view: ActiveView,
previous_view: Option<ActiveView>,
background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
draft_threads: HashMap<DraftId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
@ -1181,6 +1192,7 @@ impl AgentPanel {
context_server_registry,
previous_view: None,
background_threads: HashMap::default(),
draft_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
thread_branch_menu_handle: PopoverMenuHandle::default(),
@ -1306,9 +1318,96 @@ impl AgentPanel {
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
self.reset_start_thread_in_to_default(cx);
let initial_content = self.take_active_draft_initial_content(cx);
self.external_thread(None, None, None, None, initial_content, true, window, cx);
let id = self.create_draft(window, cx);
self.activate_draft(id, true, window, cx);
}
/// Creates a new empty draft thread and stores it. Returns the DraftId.
/// The draft is NOT activated — call `activate_draft` to show it.
pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) -> DraftId {
let id = DraftId::next(cx);
let workspace = self.workspace.clone();
let project = self.project.clone();
let fs = self.fs.clone();
let thread_store = self.thread_store.clone();
let agent = if self.project.read(cx).is_via_collab() {
Agent::NativeAgent
} else {
self.selected_agent.clone()
};
let server = agent.server(fs, thread_store);
let conversation_view = self.create_agent_thread(
server, None, None, None, None, workspace, project, agent, window, cx,
);
self.draft_threads.insert(id, conversation_view);
id
}
pub fn activate_draft(
&mut self,
id: DraftId,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(conversation_view) = self.draft_threads.get(&id).cloned() else {
return;
};
self.set_active_view(
ActiveView::AgentThread { conversation_view },
focus,
window,
cx,
);
}
/// Removes a draft thread. If it's currently active, does nothing to
/// the active view — the caller should activate something else first.
pub fn remove_draft(&mut self, id: DraftId) {
self.draft_threads.remove(&id);
}
/// Returns the DraftId of the currently active draft, if the active
/// view is a draft thread tracked in `draft_threads`.
pub fn active_draft_id(&self) -> Option<DraftId> {
let active_cv = self.active_conversation_view()?;
self.draft_threads
.iter()
.find_map(|(id, cv)| (cv.entity_id() == active_cv.entity_id()).then_some(*id))
}
/// Returns all draft IDs, sorted newest-first.
pub fn draft_ids(&self) -> Vec<DraftId> {
let mut ids: Vec<DraftId> = self.draft_threads.keys().copied().collect();
ids.sort_by_key(|id| std::cmp::Reverse(id.0));
ids
}
/// Returns the text from a draft's message editor, or `None` if the
/// draft doesn't exist or has no text.
pub fn draft_editor_text(&self, id: DraftId, cx: &App) -> Option<String> {
let cv = self.draft_threads.get(&id)?;
let tv = cv.read(cx).active_thread()?;
let text = tv.read(cx).message_editor.read(cx).text(cx);
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
/// Clears the message editor text of a tracked draft.
pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context<Self>) {
let Some(cv) = self.draft_threads.get(&id) else {
return;
};
let Some(tv) = cv.read(cx).active_thread() else {
return;
};
let editor = tv.read(cx).message_editor.clone();
editor.update(cx, |editor, cx| {
editor.clear(window, cx);
});
}
fn take_active_draft_initial_content(
@ -1410,7 +1509,7 @@ impl AgentPanel {
});
let server = agent.server(fs, thread_store);
self.create_agent_thread(
let conversation_view = self.create_agent_thread(
server,
resume_session_id,
work_dirs,
@ -1419,6 +1518,11 @@ impl AgentPanel {
workspace,
project,
agent,
window,
cx,
);
self.set_active_view(
ActiveView::AgentThread { conversation_view },
focus,
window,
cx,
@ -1982,6 +2086,16 @@ impl AgentPanel {
return;
};
// If this ConversationView is a tracked draft, it's already
// stored in `draft_threads` — don't drop it.
let is_tracked_draft = self
.draft_threads
.values()
.any(|cv| cv.entity_id() == conversation_view.entity_id());
if is_tracked_draft {
return;
}
let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
return;
};
@ -2188,6 +2302,12 @@ impl AgentPanel {
this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
}
AcpThreadViewEvent::MessageSentOrQueued => {
// When a draft sends its first message it becomes a
// real thread. Remove it from `draft_threads` so the
// sidebar stops showing a stale draft entry.
if let Some(draft_id) = this.active_draft_id() {
this.draft_threads.remove(&draft_id);
}
let session_id = view.read(cx).thread.read(cx).session_id().clone();
cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id });
}
@ -2528,10 +2648,9 @@ impl AgentPanel {
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
agent: Agent,
focus: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
) -> Entity<ConversationView> {
if self.selected_agent != agent {
self.selected_agent = agent.clone();
self.serialize(cx);
@ -2586,12 +2705,7 @@ impl AgentPanel {
})
.detach();
self.set_active_view(
ActiveView::AgentThread { conversation_view },
focus,
window,
cx,
);
conversation_view
}
fn active_thread_has_messages(&self, cx: &App) -> bool {
@ -3457,8 +3571,8 @@ impl Panel for AgentPanel {
Some((_, WorktreeCreationStatus::Creating))
)
{
let selected_agent = self.selected_agent.clone();
self.new_agent_thread_inner(selected_agent, false, window, cx);
let id = self.create_draft(window, cx);
self.activate_draft(id, false, window, cx);
}
}
@ -4745,8 +4859,14 @@ impl AgentPanel {
id: server.agent_id(),
};
self.create_agent_thread(
server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
let conversation_view = self.create_agent_thread(
server, None, None, None, None, workspace, project, ext_agent, window, cx,
);
self.set_active_view(
ActiveView::AgentThread { conversation_view },
true,
window,
cx,
);
}

View file

@ -65,11 +65,11 @@ use std::any::TypeId;
use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, WorktreeCreationStatus};
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, DraftId, WorktreeCreationStatus};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub(crate) use conversation_view::ConversationView;
pub use conversation_view::ConversationView;
pub use external_source_prompt::ExternalSourcePrompt;
pub(crate) use mode_selector::ModeSelector;
pub(crate) use model_selector::ModelSelector;

View file

@ -342,9 +342,9 @@ impl Render for ThreadImportModal {
Modal::new("import-threads", None)
.header(
ModalHeader::new()
.headline("Import ACP Threads")
.headline("Import External Agent Threads")
.description(
"Import threads from your ACP agents — whether started in Zed or another client. \
"Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
Choose which agents to include, and their threads will appear in your archive."
)
.show_dismiss_button(true),

View file

@ -139,16 +139,6 @@ pub fn build_root_plan(
.then_some((snapshot, repo))
});
let matching_worktree_snapshot = workspaces.iter().find_map(|workspace| {
workspace
.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
.find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())
.map(|worktree| worktree.read(cx).snapshot())
});
let (main_repo_path, worktree_repo, branch_name) =
if let Some((linked_snapshot, repo)) = linked_repo {
(
@ -160,12 +150,11 @@ pub fn build_root_plan(
.map(|branch| branch.name().to_string()),
)
} else {
let main_repo_path = matching_worktree_snapshot
.as_ref()?
.root_repo_common_dir()
.and_then(|dir| dir.parent())?
.to_path_buf();
(main_repo_path, None, None)
// Not a linked worktree — nothing to archive from disk.
// `remove_root` would try to remove the main worktree from
// the project and then run `git worktree remove`, both of
// which fail for main working trees.
return None;
};
Some(RootPlan {

View file

@ -553,7 +553,6 @@ impl ThreadsArchiveView {
base.status(AgentThreadStatus::Running)
.action_slot(
IconButton::new("cancel-restore", IconName::Close)
.style(ButtonStyle::Filled)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Cancel Restore"))
@ -568,12 +567,11 @@ impl ThreadsArchiveView {
})
}),
)
.tooltip(Tooltip::text("Restoring\u{2026}"))
.tooltip(Tooltip::text("Restoring"))
.into_any_element()
} else {
base.action_slot(
IconButton::new("delete-thread", IconName::Trash)
.style(ButtonStyle::Filled)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip({

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,7 @@ fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &st
#[track_caller]
fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
assert!(
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == workspace),
"{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
workspace.entity_id(),
sidebar.active_entry,
@ -340,11 +340,6 @@ fn visible_entries_as_strings(
} else {
""
};
let is_active = sidebar
.active_entry
.as_ref()
.is_some_and(|active| active.matches_entry(entry));
let active_indicator = if is_active { " (active)" } else { "" };
match entry {
ListEntry::ProjectHeader {
label,
@ -377,7 +372,7 @@ fn visible_entries_as_strings(
""
};
let worktree = format_linked_worktree_chips(&thread.worktrees);
format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}")
format!(" {title}{worktree}{live}{status_str}{notified}{selected}")
}
ListEntry::ViewMore {
is_fully_expanded, ..
@ -388,17 +383,14 @@ fn visible_entries_as_strings(
format!(" + View More{}", selected)
}
}
ListEntry::DraftThread {
workspace,
worktrees,
..
} => {
ListEntry::DraftThread { worktrees, .. } => {
let worktree = format_linked_worktree_chips(worktrees);
if workspace.is_some() {
format!(" [+ New Thread{}]{}", worktree, selected)
} else {
format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected)
}
let is_active = sidebar
.active_entry
.as_ref()
.is_some_and(|e| e.matches_entry(entry));
let active_marker = if is_active { " *" } else { "" };
format!(" [~ Draft{}]{}{}", worktree, active_marker, selected)
}
}
})
@ -566,10 +558,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [my-project]",
]
vec!["v [my-project]", " [~ Draft]"]
);
}
@ -1329,13 +1318,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
// An empty project has only the header.
// An empty project has the header and an auto-created draft.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [empty-project]",
]
vec!["v [empty-project]", " [~ Draft]"]
);
// Focus sidebar — focus_in does not set a selection
@ -1346,7 +1332,11 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
// At the end (only one entry), wraps back to first entry
// SelectNext advances to index 1 (draft entry)
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
// At the end (two entries), wraps back to first entry
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
@ -1470,7 +1460,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
vec![
//
"v [my-project]",
" Hello * (active)",
" Hello *",
" Hello * (running)",
]
);
@ -1568,7 +1558,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
vec![
//
"v [project-a]",
" Hello * (running) (active)",
" Hello * (running)",
]
);
@ -1582,7 +1572,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
vec![
//
"v [project-a]",
" Hello * (!) (active)",
" Hello * (!)",
]
);
}
@ -2274,7 +2264,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
vec![
//
"v [my-project]",
" Hello * (active)",
" Hello *",
]
);
@ -2300,7 +2290,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
vec![
//
"v [my-project]",
" Friendly Greeting with AI * (active)",
" Friendly Greeting with AI *",
]
);
}
@ -2558,7 +2548,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
vec![
//
"v [project-a]",
" Hello * (active)",
" Hello *",
]
);
@ -2591,9 +2581,8 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project-a, project-b]",
" Hello * (active)",
"v [project-a, project-b]", //
" Hello *",
]
);
@ -3126,7 +3115,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext)
vec![
//
"v [project-a, project-b]",
" [~ Draft] (active)",
" Thread B",
"v [project-a]",
" Thread A",
@ -3207,7 +3195,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext)
vec![
//
"v [project-a, project-b]",
" [~ Draft] (active)",
" Thread A",
" Worktree Thread {project-a:wt-feature}",
" Thread B",
@ -3327,7 +3314,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext
vec![
//
"v [project]",
" [~ Draft {wt-feature}] (active)",
" Worktree Thread {wt-feature}",
" Main Thread",
]
@ -3386,7 +3372,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext
vec![
//
"v [other-project, project]",
" [~ Draft {project:wt-feature}] (active)",
" Worktree Thread {project:wt-feature}",
" Main Thread",
]
@ -3421,7 +3406,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
vec![
//
"v [my-project]",
" Hello * (active)",
" Hello *",
]
);
@ -3437,12 +3422,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [my-project]",
" [~ Draft] (active)",
" Hello *",
],
vec!["v [my-project]", " [~ Draft] *", " Hello *"],
"After Cmd-N the sidebar should show a highlighted Draft entry"
);
@ -3478,25 +3458,20 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
vec![
//
"v [my-project]",
" Hello * (active)",
" Hello *",
]
);
// Open a new draft thread via a server connection. This gives the
// conversation a parent_id (session assigned by the server) but
// no messages have been sent, so active_thread_is_draft() is true.
let draft_connection = StubAgentConnection::new();
open_thread_with_connection(&panel, draft_connection, cx);
// Create a new draft via Cmd-N. Since new_thread() now creates a
// tracked draft in the AgentPanel, it appears in the sidebar.
panel.update_in(cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [my-project]",
" [~ Draft] (active)",
" Hello *",
],
vec!["v [my-project]", " [~ Draft] *", " Hello *"],
);
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
@ -3509,6 +3484,80 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
});
}
#[gpui::test]
async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) {
// When the user sends a message from a draft thread, the draft
// should be removed from the sidebar and the active_entry should
// transition to a Thread pointing at the new session.
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// Create a saved thread so the group isn't empty.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Done".into()),
)]);
open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let existing_session_id = active_session_id(&panel, cx);
save_test_thread_metadata(&existing_session_id, &project, cx).await;
cx.run_until_parked();
// Create a draft via Cmd-N.
panel.update_in(cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
});
cx.run_until_parked();
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec!["v [my-project]", " [~ Draft] *", " Hello *"],
"draft should be visible before sending",
);
sidebar.read_with(cx, |sidebar, _| {
assert_active_draft(sidebar, &workspace, "should be on draft before sending");
});
// Simulate what happens when a draft sends its first message:
// the AgentPanel's MessageSentOrQueued handler removes the draft
// from `draft_threads`, then the sidebar rebuilds. We can't use
// the NativeAgentServer in tests, so replicate the key steps:
// remove the draft, open a real thread with a stub connection,
// and send.
let draft_id = panel.read_with(cx, |panel, _| panel.active_draft_id().unwrap());
panel.update_in(cx, |panel, _window, _cx| {
panel.remove_draft(draft_id);
});
let draft_connection = StubAgentConnection::new();
draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("World".into()),
)]);
open_thread_with_connection(&panel, draft_connection, cx);
send_message(&panel, cx);
let new_session_id = active_session_id(&panel, cx);
save_test_thread_metadata(&new_session_id, &project, cx).await;
cx.run_until_parked();
// The draft should be gone and the new thread should be active.
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(
draft_count, 0,
"draft should be removed after sending a message"
);
sidebar.read_with(cx, |sidebar, _| {
assert_active_thread(
sidebar,
&new_session_id,
"active_entry should transition to the new thread after sending",
);
});
}
#[gpui::test]
async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
// When the active workspace is an absorbed git worktree, cmd-n
@ -3593,7 +3642,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
vec![
//
"v [project]",
" Hello {wt-feature-a} * (active)",
" Hello {wt-feature-a} *",
]
);
@ -3611,8 +3660,8 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
vec![
//
"v [project]",
" [~ Draft {wt-feature-a}] (active)",
" Hello {wt-feature-a} *",
" [~ Draft {wt-feature-a}] *",
" Hello {wt-feature-a} *"
],
"After Cmd-N in an absorbed worktree, the sidebar should show \
a highlighted Draft entry under the main repo header"
@ -3729,11 +3778,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
// The chip name is derived from the path even before git discovery.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project]",
" Worktree Thread {rosewood}",
]
vec!["v [project]", " Worktree Thread {rosewood}"]
);
// Now add the worktree to the git state and trigger a rescan.
@ -3925,12 +3970,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
// appears as a "New Thread" button with its worktree chip.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project]",
" [+ New Thread {wt-feature-b}]",
" Thread A {wt-feature-a}",
]
vec!["v [project]", " Thread A {wt-feature-a}",]
);
}
@ -4184,12 +4224,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
let entries = visible_entries_as_strings(&sidebar, cx);
assert_eq!(
entries,
vec![
//
"v [project]",
" [~ Draft] (active)",
" Hello {wt-feature-a} * (running)",
]
vec!["v [project]", " Hello {wt-feature-a} * (running)",]
);
}
@ -4272,12 +4307,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project]",
" [~ Draft] (active)",
" Hello {wt-feature-a} * (running)",
]
vec!["v [project]", " Hello {wt-feature-a} * (running)",]
);
connection.end_turn(session_id, acp::StopReason::EndTurn);
@ -4285,12 +4315,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project]",
" [~ Draft] (active)",
" Hello {wt-feature-a} * (!)",
]
vec!["v [project]", " Hello {wt-feature-a} * (!)",]
);
}
@ -5498,6 +5523,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
vec![
//
"v [other, project]",
" [~ Draft]",
"v [project]",
" Worktree Thread {wt-feature-a}",
]
@ -5931,6 +5957,12 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
let panel_b = add_agent_panel(&workspace_b, cx);
cx.run_until_parked();
// Explicitly create a draft on workspace_b so the sidebar tracks one.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&workspace_b, window, cx);
});
cx.run_until_parked();
// --- Scenario 1: archive a thread in the non-active workspace ---
// Create a thread in project-a (non-active — project-b is active).
@ -5951,7 +5983,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
// active_entry should still be a draft on workspace_b (the active one).
sidebar.read_with(cx, |sidebar, _| {
assert!(
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
"expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
sidebar.active_entry,
);
@ -5986,7 +6018,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
// Should fall back to a draft on the same workspace.
sidebar.read_with(cx, |sidebar, _| {
assert!(
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
"expected Draft(workspace_b) after archiving active thread, got: {:?}",
sidebar.active_entry,
);
@ -5996,9 +6028,8 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
// When a thread is archived while the user is in a different workspace,
// the archiving code clears the thread from its panel (via
// `clear_active_thread`). Switching back to that workspace should show
// a draft, not the archived thread.
// the archiving code replaces the thread with a tracked draft in its
// panel. Switching back to that workspace should show the draft.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
ThreadStore::init_global(cx);
@ -6059,7 +6090,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut Test
sidebar.read_with(cx, |sidebar, _| {
assert!(
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a),
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_a),
"expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
sidebar.active_entry,
);
@ -6561,9 +6592,10 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
#[gpui::test]
async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
// When a linked worktree is opened as its own workspace and the user
// switches away, the workspace must still be reachable from a DraftThread
// sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
// entry should remove the workspace.
// creates a draft thread from it, then switches away, the workspace must
// still be reachable from that DraftThread sidebar entry. Pressing
// RemoveSelectedThread (shift-backspace) on that entry should remove the
// workspace.
init_test(cx);
let fs = FakeFs::new(cx.executor());
@ -6627,6 +6659,14 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
add_agent_panel(&worktree_workspace, cx);
cx.run_until_parked();
// Explicitly create a draft thread from the linked worktree workspace.
// Auto-created drafts use the group's first workspace (the main one),
// so a user-created draft is needed to make the linked worktree reachable.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&worktree_workspace, window, cx);
});
cx.run_until_parked();
// Switch back to the main workspace.
multi_workspace.update_in(cx, |mw, window, cx| {
let main_ws = mw.workspaces().next().unwrap().clone();
@ -6656,7 +6696,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
"linked worktree workspace should be reachable, but reachable are: {reachable:?}"
);
// Find the DraftThread entry for the linked worktree and dismiss it.
// Find the DraftThread entry whose workspace is the linked worktree.
let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
sidebar
.contents
@ -6666,9 +6706,9 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
matches!(
entry,
ListEntry::DraftThread {
workspace: Some(_),
workspace: Some(ws),
..
}
} if ws.entity_id() == worktree_ws_id
)
})
.expect("expected a DraftThread entry for the linked worktree")
@ -6687,8 +6727,25 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
assert_eq!(
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
1,
"linked worktree workspace should be removed after dismissing DraftThread entry"
2,
"dismissing a draft no longer removes the linked worktree workspace"
);
let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
sidebar.contents.entries.iter().any(|entry| {
matches!(
entry,
ListEntry::DraftThread {
draft_id: Some(_),
workspace: Some(ws),
..
} if ws.entity_id() == worktree_ws_id
)
})
});
assert!(
!has_draft_for_worktree,
"DraftThread entry for the linked worktree should be removed after dismiss"
);
}
@ -7226,6 +7283,372 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project
);
}
#[gpui::test]
async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) {
// Rule 4: When the app starts and the AgentPanel fails to restore its
// last thread (no metadata), a draft should appear in the sidebar.
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// In tests, AgentPanel::test_new doesn't call `load`, so no
// fallback draft is created. The empty group shows a placeholder.
// Simulate the startup fallback by creating a draft explicitly.
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&workspace, window, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec!["v [my-project]", " [~ Draft] *"]
);
sidebar.read_with(cx, |sidebar, _| {
assert_active_draft(sidebar, &workspace, "should show active draft");
});
}
#[gpui::test]
async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) {
// Rule 5: When the app starts and the AgentPanel successfully loads
// a thread, no spurious draft should appear.
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// Create and send a message to make a real thread.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Done".into()),
)]);
open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
// Should show the thread, NOT a spurious draft.
let entries = visible_entries_as_strings(&sidebar, cx);
assert_eq!(entries, vec!["v [my-project]", " Hello *"]);
// active_entry should be Thread, not Draft.
sidebar.read_with(cx, |sidebar, _| {
assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft");
});
}
#[gpui::test]
async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) {
// Rule 8: Deleting the last draft in a threadless group should
// leave a placeholder draft entry (not an empty group).
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// Create two drafts explicitly (test_new doesn't call load).
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&workspace, window, cx);
});
cx.run_until_parked();
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&workspace, window, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec!["v [my-project]", " [~ Draft] *", " [~ Draft]"]
);
// Delete the active (first) draft. The second should become active.
let active_draft_id = sidebar.read_with(cx, |_sidebar, cx| {
workspace
.read(cx)
.panel::<AgentPanel>(cx)
.unwrap()
.read(cx)
.active_draft_id()
.unwrap()
});
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.remove_draft(active_draft_id, &workspace, window, cx);
});
cx.run_until_parked();
// Should still have 1 draft (the remaining one), now active.
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(draft_count, 1, "one draft should remain after deleting one");
// Delete the last remaining draft.
let last_draft_id = sidebar.read_with(cx, |_sidebar, cx| {
workspace
.read(cx)
.panel::<AgentPanel>(cx)
.unwrap()
.read(cx)
.active_draft_id()
.unwrap()
});
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.remove_draft(last_draft_id, &workspace, window, cx);
});
cx.run_until_parked();
// The group has no threads and no tracked drafts, so a
// placeholder draft should appear.
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(
draft_count, 1,
"placeholder draft should appear after deleting all tracked drafts"
);
}
#[gpui::test]
async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
// Rule 9: Clicking a project header should restore whatever the
// user was last looking at in that group, not create new drafts
// or jump to the first entry.
let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// Create two threads in project-a.
let conn1 = StubAgentConnection::new();
conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Done".into()),
)]);
open_thread_with_connection(&panel_a, conn1, cx);
send_message(&panel_a, cx);
let thread_a1 = active_session_id(&panel_a, cx);
save_test_thread_metadata(&thread_a1, &project_a, cx).await;
let conn2 = StubAgentConnection::new();
conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Done".into()),
)]);
open_thread_with_connection(&panel_a, conn2, cx);
send_message(&panel_a, cx);
let thread_a2 = active_session_id(&panel_a, cx);
save_test_thread_metadata(&thread_a2, &project_a, cx).await;
cx.run_until_parked();
// The user is now looking at thread_a2.
sidebar.read_with(cx, |sidebar, _| {
assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
});
// Add project-b and switch to it.
let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
fs.as_fake()
.insert_tree("/project-b", serde_json::json!({ "src": {} }))
.await;
let project_b =
project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b.clone(), window, cx)
});
let _panel_b = add_agent_panel(&workspace_b, cx);
cx.run_until_parked();
// Now switch BACK to project-a by activating its workspace.
let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
mw.workspaces()
.find(|ws| {
ws.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
.any(|wt| {
wt.read(cx)
.abs_path()
.to_string_lossy()
.contains("project-a")
})
})
.unwrap()
.clone()
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate(workspace_a.clone(), window, cx);
});
cx.run_until_parked();
// The panel should still show thread_a2 (the last thing the user
// was viewing in project-a), not a draft or thread_a1.
sidebar.read_with(cx, |sidebar, _| {
assert_active_thread(
sidebar,
&thread_a2,
"switching back to project-a should restore thread_a2",
);
});
// No spurious draft entries should have been created in
// project-a's group (project-b may have a placeholder).
let entries = visible_entries_as_strings(&sidebar, cx);
// Find project-a's section and check it has no drafts.
let project_a_start = entries
.iter()
.position(|e| e.contains("project-a"))
.unwrap();
let project_a_end = entries[project_a_start + 1..]
.iter()
.position(|e| e.starts_with("v "))
.map(|i| i + project_a_start + 1)
.unwrap_or(entries.len());
let project_a_drafts = entries[project_a_start..project_a_end]
.iter()
.filter(|e| e.contains("Draft"))
.count();
assert_eq!(
project_a_drafts, 0,
"switching back to project-a should not create drafts in its group"
);
}
#[gpui::test]
async fn test_plus_button_always_creates_new_draft(cx: &mut TestAppContext) {
// Rule 3: Clicking the + button on a group should always create
// a new draft, even starting from a placeholder (no tracked drafts).
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
// Start: panel has no tracked drafts, sidebar shows a placeholder.
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(draft_count, 1, "should start with 1 placeholder");
// Simulate what the + button handler does: create exactly one
// new draft per click.
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let simulate_plus_button =
|sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
sidebar.create_new_thread(&workspace, window, cx);
};
// First + click: placeholder -> 1 tracked draft.
sidebar.update_in(cx, |sidebar, window, cx| {
simulate_plus_button(sidebar, window, cx);
});
cx.run_until_parked();
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(
draft_count, 1,
"first + click on placeholder should produce 1 tracked draft"
);
// Second + click: 1 -> 2 drafts.
sidebar.update_in(cx, |sidebar, window, cx| {
simulate_plus_button(sidebar, window, cx);
});
cx.run_until_parked();
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(draft_count, 2, "second + click should add 1 more draft");
// Third + click: 2 -> 3 drafts.
sidebar.update_in(cx, |sidebar, window, cx| {
simulate_plus_button(sidebar, window, cx);
});
cx.run_until_parked();
let entries = visible_entries_as_strings(&sidebar, cx);
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
assert_eq!(draft_count, 3, "third + click should add 1 more draft");
// The most recently created draft should be active (first in list).
assert_eq!(entries[1], " [~ Draft] *");
}
#[gpui::test]
async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
// When a workspace has a draft (from the panel's load fallback)
// and the user activates it (e.g. by clicking the placeholder or
// the project header), no extra drafts should be created.
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
.await;
fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
.await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let project_a =
project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let _panel_a = add_agent_panel(&workspace_a, cx);
cx.run_until_parked();
// Add project-b with its own workspace and agent panel.
let project_b =
project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b.clone(), window, cx)
});
let _panel_b = add_agent_panel(&workspace_b, cx);
cx.run_until_parked();
// Explicitly create a draft on workspace_b so the sidebar tracks one.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.create_new_thread(&workspace_b, window, cx);
});
cx.run_until_parked();
// Count project-b's drafts.
let count_b_drafts = |cx: &mut gpui::VisualTestContext| {
let entries = visible_entries_as_strings(&sidebar, cx);
entries
.iter()
.skip_while(|e| !e.contains("project-b"))
.take_while(|e| !e.starts_with("v ") || e.contains("project-b"))
.filter(|e| e.contains("Draft"))
.count()
};
let drafts_before = count_b_drafts(cx);
// Switch away from project-b, then back.
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate(workspace_a.clone(), window, cx);
});
cx.run_until_parked();
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate(workspace_b.clone(), window, cx);
});
cx.run_until_parked();
let drafts_after = count_b_drafts(cx);
assert_eq!(
drafts_before, drafts_after,
"activating workspace should not create extra drafts"
);
// The draft should be highlighted as active after switching back.
sidebar.read_with(cx, |sidebar, _| {
assert_active_draft(
sidebar,
&workspace_b,
"draft should be active after switching back to its workspace",
);
});
}
mod property_test {
use super::*;
use gpui::proptest::prelude::*;
@ -7462,8 +7885,9 @@ mod property_test {
let panel =
workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
if let Some(panel) = panel {
let connection = StubAgentConnection::new();
open_thread_with_connection(&panel, connection, cx);
panel.update_in(cx, |panel, window, cx| {
panel.new_thread(&NewThread, window, cx);
});
cx.run_until_parked();
}
workspace.update_in(cx, |workspace, window, cx| {
@ -7880,11 +8304,29 @@ mod property_test {
let active_workspace = multi_workspace.read(cx).workspace();
// 1. active_entry must always be Some after rebuild_contents.
let entry = sidebar
.active_entry
.as_ref()
.ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
// 1. active_entry should be Some when the panel has content.
// It may be None when the panel is uninitialized (no drafts,
// no threads), which is fine.
// It may also temporarily point at a different workspace
// when the workspace just changed and the new panel has no
// content yet.
let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
let panel_has_content = panel.read(cx).active_draft_id().is_some()
|| panel.read(cx).active_conversation_view().is_some();
let Some(entry) = sidebar.active_entry.as_ref() else {
if panel_has_content {
anyhow::bail!("active_entry is None but panel has content (draft or thread)");
}
return Ok(());
};
// If the entry workspace doesn't match the active workspace
// and the panel has no content, this is a transient state that
// will resolve when the panel gets content.
if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
return Ok(());
}
// 2. The entry's workspace must agree with the multi-workspace's
// active workspace.
@ -7896,11 +8338,10 @@ mod property_test {
);
// 3. The entry must match the agent panel's current state.
let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
if panel.read(cx).active_thread_is_draft(cx) {
if panel.read(cx).active_draft_id().is_some() {
anyhow::ensure!(
matches!(entry, ActiveEntry::Draft(_)),
"panel shows a draft but active_entry is {:?}",
matches!(entry, ActiveEntry::Draft { .. }),
"panel shows a tracked draft but active_entry is {:?}",
entry,
);
} else if let Some(session_id) = panel