Detailed changes
@@ -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,
);
}
@@ -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;
@@ -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),
@@ -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 {
@@ -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({
@@ -9,9 +9,9 @@ use agent_ui::thread_worktree_archive;
use agent_ui::threads_archive_view::{
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
};
-use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal};
use agent_ui::{
- Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
+ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, DraftId,
+ NewThread, RemoveSelectedThread, ThreadImportModal,
};
use chrono::{DateTime, Utc};
use editor::Editor;
@@ -38,9 +38,9 @@ use std::path::PathBuf;
use std::rc::Rc;
use theme::ActiveTheme;
use ui::{
- AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding,
- PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip,
- WithScrollbar, prelude::*,
+ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel,
+ KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor,
+ Tooltip, WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use util::path_list::PathList;
@@ -121,14 +121,17 @@ enum ActiveEntry {
session_id: acp::SessionId,
workspace: Entity<Workspace>,
},
- Draft(Entity<Workspace>),
+ Draft {
+ id: DraftId,
+ workspace: Entity<Workspace>,
+ },
}
impl ActiveEntry {
fn workspace(&self) -> &Entity<Workspace> {
match self {
ActiveEntry::Thread { workspace, .. } => workspace,
- ActiveEntry::Draft(workspace) => workspace,
+ ActiveEntry::Draft { workspace, .. } => workspace,
}
}
@@ -136,17 +139,22 @@ impl ActiveEntry {
matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id)
}
+ fn is_active_draft(&self, draft_id: DraftId) -> bool {
+ matches!(self, ActiveEntry::Draft { id, .. } if *id == draft_id)
+ }
+
fn matches_entry(&self, entry: &ListEntry) -> bool {
match (self, entry) {
(ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
thread.metadata.session_id == *session_id
}
(
- ActiveEntry::Draft(_),
+ ActiveEntry::Draft { id, .. },
ListEntry::DraftThread {
- workspace: None, ..
+ draft_id: Some(entry_id),
+ ..
},
- ) => true,
+ ) => *id == *entry_id,
_ => false,
}
}
@@ -245,9 +253,10 @@ enum ListEntry {
key: ProjectGroupKey,
is_fully_expanded: bool,
},
- /// The user's active draft thread. Shows a prefix of the currently-typed
- /// prompt, or "Untitled Thread" if the prompt is empty.
DraftThread {
+ /// `None` for placeholder entries in empty groups with no open
+ /// workspace. `Some` for drafts backed by an AgentPanel.
+ draft_id: Option<DraftId>,
key: project::ProjectGroupKey,
workspace: Option<Entity<Workspace>>,
worktrees: Vec<WorktreeInfo>,
@@ -273,15 +282,7 @@ impl ListEntry {
ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
ThreadEntryWorkspace::Closed { .. } => Vec::new(),
},
- ListEntry::DraftThread { workspace, .. } => {
- if let Some(ws) = workspace {
- vec![ws.clone()]
- } else {
- // workspace: None means this is the active draft,
- // which always lives on the current workspace.
- vec![multi_workspace.workspace().clone()]
- }
- }
+ ListEntry::DraftThread { workspace, .. } => workspace.iter().cloned().collect(),
ListEntry::ProjectHeader { key, .. } => multi_workspace
.workspaces_for_project_group(key, cx)
.cloned()
@@ -595,10 +596,6 @@ impl Sidebar {
cx.emit(workspace::SidebarEvent::SerializeNeeded);
}
- fn active_entry_workspace(&self) -> Option<&Entity<Workspace>> {
- self.active_entry.as_ref().map(|entry| entry.workspace())
- }
-
fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
self.multi_workspace
.upgrade()
@@ -648,10 +645,10 @@ impl Sidebar {
cx.subscribe_in(
workspace,
window,
- |this, _workspace, event: &workspace::Event, window, cx| {
+ |this, _workspace, event: &workspace::Event, _window, cx| {
if let workspace::Event::PanelAdded(view) = event {
if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
- this.subscribe_to_agent_panel(&agent_panel, window, cx);
+ this.subscribe_to_agent_panel(&agent_panel, _window, cx);
}
}
},
@@ -675,21 +672,8 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
- |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+ |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged => {
- let is_new_draft = agent_panel
- .read(cx)
- .active_conversation_view()
- .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
- if is_new_draft {
- if let Some(active_workspace) = this
- .multi_workspace
- .upgrade()
- .map(|mw| mw.read(cx).workspace().clone())
- {
- this.active_entry = Some(ActiveEntry::Draft(active_workspace));
- }
- }
this.observe_draft_editor(cx);
this.update_entries(cx);
}
@@ -749,26 +733,6 @@ impl Sidebar {
});
}
- fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
- let mw = self.multi_workspace.upgrade()?;
- let workspace = mw.read(cx).workspace();
- let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
- let conversation_view = panel.read(cx).active_conversation_view()?;
- let thread_view = conversation_view.read(cx).active_thread()?;
- let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
- let cleaned = Self::clean_mention_links(&raw);
- let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
- if text.is_empty() {
- None
- } else {
- const MAX_CHARS: usize = 250;
- if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
- text.truncate(truncate_at);
- }
- Some(text.into())
- }
- }
-
fn clean_mention_links(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut remaining = input;
@@ -829,6 +793,42 @@ impl Sidebar {
.detach_and_log_err(cx);
}
+ fn open_workspace_and_create_draft(
+ &mut self,
+ project_group_key: &ProjectGroupKey,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let path_list = project_group_key.path_list().clone();
+ let host = project_group_key.host();
+ let provisional_key = Some(project_group_key.clone());
+ let active_workspace = multi_workspace.read(cx).workspace().clone();
+
+ let task = multi_workspace.update(cx, |this, cx| {
+ this.find_or_create_workspace(
+ path_list,
+ host,
+ provisional_key,
+ |options, window, cx| connect_remote(active_workspace, options, window, cx),
+ window,
+ cx,
+ )
+ });
+
+ cx.spawn_in(window, async move |this, cx| {
+ let workspace = task.await?;
+ this.update_in(cx, |this, window, cx| {
+ this.create_new_thread(&workspace, window, cx);
+ })?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
/// Rebuilds the sidebar contents from current workspace and thread state.
///
/// Iterates [`MultiWorkspace::project_group_keys`] to determine project
@@ -859,43 +859,21 @@ impl Sidebar {
let query = self.filter_editor.read(cx).text(cx);
// Derive active_entry from the active workspace's agent panel.
- // Draft is checked first because a conversation can have a session_id
- // before any messages are sent. However, a thread that's still loading
- // also appears as a "draft" (no messages yet).
+ // A tracked draft (in `draft_threads`) is checked first via
+ // `active_draft_id`. Then we check for a thread with a session_id.
+ // If a thread is mid-load with no session_id yet, we fall back to
+ // `pending_remote_thread_activation` or keep the previous value.
if let Some(active_ws) = &active_workspace {
if let Some(panel) = active_ws.read(cx).panel::<AgentPanel>(cx) {
- let active_thread_is_draft = panel.read(cx).active_thread_is_draft(cx);
- let active_conversation_view = panel.read(cx).active_conversation_view();
-
- if active_thread_is_draft || active_conversation_view.is_none() {
- if active_conversation_view.is_none()
- && let Some(session_id) = self.pending_remote_thread_activation.clone()
- {
- self.active_entry = Some(ActiveEntry::Thread {
- session_id,
- workspace: active_ws.clone(),
- });
- } else {
- let conversation_parent_id =
- active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx));
- let preserving_thread = if let Some(ActiveEntry::Thread {
- session_id,
- ..
- }) = &self.active_entry
- {
- self.active_entry_workspace() == Some(active_ws)
- && conversation_parent_id
- .as_ref()
- .is_some_and(|id| id == session_id)
- } else {
- false
- };
- if !preserving_thread {
- self.active_entry = Some(ActiveEntry::Draft(active_ws.clone()));
- }
- }
- } else if let Some(session_id) =
- active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx))
+ let panel = panel.read(cx);
+ if let Some(draft_id) = panel.active_draft_id() {
+ self.active_entry = Some(ActiveEntry::Draft {
+ id: draft_id,
+ workspace: active_ws.clone(),
+ });
+ } else if let Some(session_id) = panel
+ .active_conversation_view()
+ .and_then(|cv| cv.read(cx).parent_id(cx))
{
if self.pending_remote_thread_activation.as_ref() == Some(&session_id) {
self.pending_remote_thread_activation = None;
@@ -904,9 +882,14 @@ impl Sidebar {
session_id,
workspace: active_ws.clone(),
});
+ } else if let Some(session_id) = self.pending_remote_thread_activation.clone() {
+ self.active_entry = Some(ActiveEntry::Thread {
+ session_id,
+ workspace: active_ws.clone(),
+ });
}
- // else: conversation exists, not a draft, but no session_id
- // yet β thread is mid-load. Keep previous value.
+ // else: conversation is mid-load or panel is
+ // uninitialized β keep previous active_entry.
}
}
@@ -1221,9 +1204,6 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
- let is_draft_for_group = is_active
- && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
-
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
key: group_key.clone(),
@@ -1239,66 +1219,43 @@ impl Sidebar {
continue;
}
- // Emit a DraftThread entry when the active draft belongs to this group.
- if is_draft_for_group {
- if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
- let ws_worktree_paths = ThreadWorktreePaths::from_project(
- draft_ws.read(cx).project().read(cx),
- cx,
- );
- let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
- entries.push(ListEntry::DraftThread {
- key: group_key.clone(),
- workspace: None,
- worktrees,
- });
- }
- }
-
- // Emit a DraftThread for each open linked worktree workspace
- // that has no threads. Skip the specific workspace that is
- // showing the active draft (it already has a DraftThread entry
- // from the block above).
+ // Emit DraftThread entries by reading draft IDs from
+ // each workspace's AgentPanel in this group.
{
- let draft_ws_id = if is_draft_for_group {
- self.active_entry.as_ref().and_then(|e| match e {
- ActiveEntry::Draft(ws) => Some(ws.entity_id()),
- _ => None,
- })
- } else {
- None
- };
- let thread_store = ThreadMetadataStore::global(cx);
+ let mut group_draft_ids: Vec<(DraftId, Entity<Workspace>)> = Vec::new();
for ws in group_workspaces {
- if Some(ws.entity_id()) == draft_ws_id {
- continue;
- }
- let ws_worktree_paths =
- ThreadWorktreePaths::from_project(ws.read(cx).project().read(cx), cx);
- let has_linked_worktrees =
- worktree_info_from_thread_paths(&ws_worktree_paths)
- .iter()
- .any(|wt| wt.kind == ui::WorktreeKind::Linked);
- if !has_linked_worktrees {
- continue;
- }
- let ws_path_list = workspace_path_list(ws, cx);
- let store = thread_store.read(cx);
- let has_threads = store.entries_for_path(&ws_path_list).next().is_some()
- || store
- .entries_for_main_worktree_path(&ws_path_list)
- .next()
- .is_some();
- if has_threads {
- continue;
+ if let Some(panel) = ws.read(cx).panel::<AgentPanel>(cx) {
+ let ids = panel.read(cx).draft_ids();
+
+ for draft_id in ids {
+ group_draft_ids.push((draft_id, ws.clone()));
+ }
}
- let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
+ }
+ // For empty groups with no drafts, emit a
+ // placeholder DraftThread.
+ if !has_threads && group_draft_ids.is_empty() {
entries.push(ListEntry::DraftThread {
+ draft_id: None,
key: group_key.clone(),
- workspace: Some(ws.clone()),
- worktrees,
+ workspace: group_workspaces.first().cloned(),
+ worktrees: Vec::new(),
});
+ } else {
+ for (draft_id, ws) in &group_draft_ids {
+ let ws_worktree_paths = ThreadWorktreePaths::from_project(
+ ws.read(cx).project().read(cx),
+ cx,
+ );
+ let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
+ entries.push(ListEntry::DraftThread {
+ draft_id: Some(*draft_id),
+ key: group_key.clone(),
+ workspace: Some(ws.clone()),
+ worktrees,
+ });
+ }
}
}
@@ -1457,15 +1414,34 @@ impl Sidebar {
is_fully_expanded,
} => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx),
ListEntry::DraftThread {
+ draft_id,
key,
workspace,
worktrees,
} => {
- if workspace.is_some() {
- self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx)
- } else {
- self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
- }
+ let group_has_threads = self
+ .contents
+ .entries
+ .iter()
+ .any(|e| matches!(e, ListEntry::ProjectHeader { key: hk, has_threads: true, .. } if hk == key));
+ // Count drafts in the AgentPanel for this group's workspaces.
+ let sibling_draft_count = workspace
+ .as_ref()
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .map(|p| p.read(cx).draft_ids().len())
+ .unwrap_or(0);
+ let can_dismiss = group_has_threads || sibling_draft_count > 1;
+ self.render_draft_thread(
+ ix,
+ *draft_id,
+ key,
+ workspace.as_ref(),
+ is_active,
+ worktrees,
+ is_selected,
+ can_dismiss,
+ cx,
+ )
}
};
@@ -1533,17 +1509,6 @@ impl Sidebar {
(IconName::ChevronDown, "Collapse Project")
};
- let has_new_thread_entry = self
- .contents
- .entries
- .get(ix + 1)
- .is_some_and(|entry| matches!(entry, ListEntry::DraftThread { .. }));
- let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
- let workspace = self.multi_workspace.upgrade().and_then(|mw| {
- mw.read(cx)
- .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
- });
-
let key_for_toggle = key.clone();
let key_for_collapse = key.clone();
let view_more_expanded = self.expanded_groups.contains_key(key);
@@ -1559,9 +1524,26 @@ impl Sidebar {
};
let color = cx.theme().colors();
- let hover_color = color
+ let sidebar_base_bg = color
+ .title_bar_background
+ .blend(color.panel_background.opacity(0.25));
+
+ let base_bg = color.background.blend(sidebar_base_bg);
+
+ let hover_base = color
.element_active
.blend(color.element_background.opacity(0.2));
+ let hover_solid = base_bg.blend(hover_base);
+ let real_hover_color = if is_active { base_bg } else { hover_solid };
+
+ let group_name_for_gradient = group_name.clone();
+ let gradient_overlay = move || {
+ GradientFade::new(base_bg, real_hover_color, real_hover_color)
+ .width(px(64.0))
+ .right(px(-2.0))
+ .gradient_stop(0.75)
+ .group_name(group_name_for_gradient.clone())
+ };
let is_ellipsis_menu_open = self.project_header_menu_ix == Some(ix);
@@ -1569,9 +1551,11 @@ impl Sidebar {
.id(id)
.group(&group_name)
.h(Tab::content_height(cx))
+ .relative()
.w_full()
.pl(px(5.))
.pr_1p5()
+ .justify_between()
.border_1()
.map(|this| {
if is_focused {
@@ -1580,7 +1564,6 @@ impl Sidebar {
this.border_color(gpui::transparent_black())
}
})
- .justify_between()
.child(
h_flex()
.relative()
@@ -1633,11 +1616,13 @@ impl Sidebar {
})
}),
)
+ .child(gradient_overlay())
.child(
h_flex()
.when(!is_ellipsis_menu_open, |this| {
this.visible_on_hover(&group_name)
})
+ .child(gradient_overlay())
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
@@ -1663,37 +1648,54 @@ impl Sidebar {
})),
)
})
- .when_some(
- workspace.filter(|_| show_new_thread_button),
- |this, workspace| {
- let key = key.clone();
- let focus_handle = self.focus_handle.clone();
- this.child(
- IconButton::new(
- SharedString::from(format!(
- "{id_prefix}project-header-new-thread-{ix}",
- )),
- IconName::Plus,
- )
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action_in(
- "New Thread",
- &NewThread,
- &focus_handle,
- cx,
- )
- })
- .on_click(cx.listener(
- move |this, _, window, cx| {
- this.collapsed_groups.remove(&key);
- this.selection = None;
- this.create_new_thread(&workspace, window, cx);
- },
- )),
+ .child({
+ let key = key.clone();
+ let focus_handle = self.focus_handle.clone();
+
+ IconButton::new(
+ SharedString::from(format!(
+ "{id_prefix}project-header-new-thread-{ix}",
+ )),
+ IconName::Plus,
+ )
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| {
+ Tooltip::for_action_in(
+ "Start New Agent Thread",
+ &NewThread,
+ &focus_handle,
+ cx,
)
- },
- ),
+ })
+ .on_click(cx.listener(
+ move |this, _, window, cx| {
+ this.collapsed_groups.remove(&key);
+ this.selection = None;
+ // If the active workspace belongs to this
+ // group, use it (preserves linked worktree
+ // context). Otherwise resolve from the key.
+ let workspace = this.multi_workspace.upgrade().and_then(|mw| {
+ let mw = mw.read(cx);
+ let active = mw.workspace().clone();
+ let active_key = active.read(cx).project_group_key(cx);
+ if active_key == key {
+ Some(active)
+ } else {
+ mw.workspace_for_paths(
+ key.path_list(),
+ key.host().as_ref(),
+ cx,
+ )
+ }
+ });
+ if let Some(workspace) = workspace {
+ this.create_new_thread(&workspace, window, cx);
+ } else {
+ this.open_workspace_and_create_draft(&key, window, cx);
+ }
+ },
+ ))
+ }),
)
.map(|this| {
if !has_threads && is_active {
@@ -1701,7 +1703,7 @@ impl Sidebar {
} else {
let key = key.clone();
this.cursor_pointer()
- .when(!is_active, |this| this.hover(|s| s.bg(hover_color)))
+ .when(!is_active, |this| this.hover(|s| s.bg(hover_solid)))
.tooltip(Tooltip::text("Open Workspace"))
.on_click(cx.listener(move |this, _, window, cx| {
if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| {
@@ -1711,12 +1713,11 @@ impl Sidebar {
cx,
)
}) {
- this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
- if let Some(multi_workspace) = this.multi_workspace.upgrade() {
- multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(workspace.clone(), window, cx);
- });
- }
+ // Just activate the workspace. The
+ // AgentPanel remembers what was last
+ // shown, so the user returns to whatever
+ // thread/draft they were looking at.
+ this.activate_workspace(&workspace, window, cx);
if AgentPanel::is_visible(&workspace, cx) {
workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
@@ -2165,16 +2166,21 @@ impl Sidebar {
self.expand_thread_group(&key, cx);
}
}
- ListEntry::DraftThread { key, workspace, .. } => {
+ ListEntry::DraftThread {
+ draft_id,
+ key,
+ workspace,
+ ..
+ } => {
+ let draft_id = *draft_id;
let key = key.clone();
let workspace = workspace.clone();
- if let Some(workspace) = workspace.or_else(|| {
- self.multi_workspace.upgrade().and_then(|mw| {
- mw.read(cx)
- .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
- })
- }) {
- self.create_new_thread(&workspace, window, cx);
+ if let Some(draft_id) = draft_id {
+ if let Some(workspace) = workspace {
+ self.activate_draft(draft_id, &workspace, window, cx);
+ }
+ } else if let Some(workspace) = workspace {
+ self.activate_workspace(&workspace, window, cx);
} else {
self.open_workspace_for_group(&key, window, cx);
}
@@ -2352,10 +2358,10 @@ impl Sidebar {
};
let pending_session_id = metadata.session_id.clone();
- let is_remote = project_group_key.host().is_some();
- if is_remote {
- self.pending_remote_thread_activation = Some(pending_session_id.clone());
- }
+ // Mark the pending thread activation so rebuild_contents
+ // preserves the Thread active_entry during loading (prevents
+ // spurious draft flash).
+ self.pending_remote_thread_activation = Some(pending_session_id.clone());
let host = project_group_key.host();
let provisional_key = Some(project_group_key.clone());
@@ -2379,7 +2385,7 @@ impl Sidebar {
// failures or cancellations do not leave a stale connection modal behind.
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
- if result.is_err() || is_remote {
+ if result.is_err() {
this.update(cx, |this, _cx| {
if this.pending_remote_thread_activation.as_ref() == Some(&pending_session_id) {
this.pending_remote_thread_activation = None;
@@ -2813,22 +2819,20 @@ impl Sidebar {
.entries_for_path(folder_paths)
.filter(|t| t.session_id != *session_id)
.count();
+
if remaining > 0 {
return None;
}
let multi_workspace = self.multi_workspace.upgrade()?;
- // Thread metadata doesn't carry host info yet, so we pass
- // `None` here. This may match a local workspace with the same
- // paths instead of the intended remote one.
let workspace = multi_workspace
.read(cx)
.workspace_for_paths(folder_paths, None, cx)?;
- // Don't remove the main worktree workspace β the project
- // header always provides access to it.
let group_key = workspace.read(cx).project_group_key(cx);
- (group_key.path_list() != folder_paths).then_some(workspace)
+ let is_linked_worktree = group_key.path_list() != folder_paths;
+
+ is_linked_worktree.then_some(workspace)
});
if let Some(workspace_to_remove) = workspace_to_remove {
@@ -2881,7 +2885,6 @@ impl Sidebar {
})
.detach_and_log_err(cx);
} else {
- // Simple case: no workspace removal needed.
let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx);
self.archive_and_activate(
@@ -2947,7 +2950,11 @@ impl Sidebar {
.is_some_and(|id| id == *session_id);
if panel_shows_archived {
panel.update(cx, |panel, cx| {
- panel.clear_active_thread(window, cx);
+ // Replace the archived thread with a
+ // tracked draft so the panel isn't left
+ // in Uninitialized state.
+ let id = panel.create_draft(window, cx);
+ panel.activate_draft(id, false, window, cx);
});
}
}
@@ -2960,6 +2967,7 @@ impl Sidebar {
// tell the panel to load it and activate that workspace.
// `rebuild_contents` will reconcile `active_entry` once the thread
// finishes loading.
+
if let Some(metadata) = neighbor {
if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
mw.read(cx)
@@ -2974,26 +2982,24 @@ impl Sidebar {
// No neighbor or its workspace isn't open β fall back to a new
// draft. Use the group workspace (main project) rather than the
// active entry workspace, which may be a linked worktree that is
- // about to be cleaned up.
+ // about to be cleaned up or already removed.
let fallback_workspace = thread_folder_paths
.and_then(|folder_paths| {
let mw = self.multi_workspace.upgrade()?;
let mw = mw.read(cx);
- // Find the group's main workspace (whose root paths match
- // the project group key, not the thread's folder paths).
let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?;
let group_key = thread_workspace.read(cx).project_group_key(cx);
mw.workspace_for_paths(group_key.path_list(), None, cx)
})
- .or_else(|| self.active_entry_workspace().cloned());
+ .or_else(|| {
+ self.multi_workspace
+ .upgrade()
+ .map(|mw| mw.read(cx).workspace().clone())
+ });
if let Some(workspace) = fallback_workspace {
self.activate_workspace(&workspace, window, cx);
- if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.new_thread(&NewThread, window, cx);
- });
- }
+ self.create_new_thread(&workspace, window, cx);
}
}
@@ -3120,35 +3126,18 @@ impl Sidebar {
self.archive_thread(&session_id, window, cx);
}
Some(ListEntry::DraftThread {
+ draft_id: Some(draft_id),
workspace: Some(workspace),
..
}) => {
- self.remove_worktree_workspace(workspace.clone(), window, cx);
+ let draft_id = *draft_id;
+ let workspace = workspace.clone();
+ self.remove_draft(draft_id, &workspace, window, cx);
}
_ => {}
}
}
- fn remove_worktree_workspace(
- &mut self,
- workspace: Entity<Workspace>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(multi_workspace) = self.multi_workspace.upgrade() {
- multi_workspace
- .update(cx, |mw, cx| {
- mw.remove(
- [workspace],
- |this, _window, _cx| gpui::Task::ready(Ok(this.workspace().clone())),
- window,
- cx,
- )
- })
- .detach_and_log_err(cx);
- }
- }
-
fn record_thread_access(&mut self, session_id: &acp::SessionId) {
self.thread_last_accessed
.insert(session_id.clone(), Utc::now());
@@ -3687,30 +3676,13 @@ impl Sidebar {
// If there is a keyboard selection, walk backwards through
// `project_header_indices` to find the header that owns the selected
// row. Otherwise fall back to the active workspace.
- let workspace = if let Some(selected_ix) = self.selection {
- self.contents
- .project_header_indices
- .iter()
- .rev()
- .find(|&&header_ix| header_ix <= selected_ix)
- .and_then(|&header_ix| match &self.contents.entries[header_ix] {
- ListEntry::ProjectHeader { key, .. } => {
- self.multi_workspace.upgrade().and_then(|mw| {
- mw.read(cx).workspace_for_paths(
- key.path_list(),
- key.host().as_ref(),
- cx,
- )
- })
- }
- _ => None,
- })
- } else {
- // Use the currently active workspace.
- self.multi_workspace
- .upgrade()
- .map(|mw| mw.read(cx).workspace().clone())
- };
+ // Always use the currently active workspace so that drafts
+ // are created in the linked worktree the user is focused on,
+ // not the main worktree resolved from the project header.
+ let workspace = self
+ .multi_workspace
+ .upgrade()
+ .map(|mw| mw.read(cx).workspace().clone());
let Some(workspace) = workspace else {
return;
@@ -3729,20 +3701,166 @@ impl Sidebar {
return;
};
- self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
-
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), window, cx);
});
- workspace.update(cx, |workspace, cx| {
- if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
- agent_panel.update(cx, |panel, cx| {
- panel.new_thread(&NewThread, window, cx);
+ let draft_id = workspace.update(cx, |workspace, cx| {
+ let panel = workspace.panel::<AgentPanel>(cx)?;
+ let draft_id = panel.update(cx, |panel, cx| {
+ let id = panel.create_draft(window, cx);
+ panel.activate_draft(id, true, window, cx);
+ id
+ });
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ Some(draft_id)
+ });
+
+ if let Some(draft_id) = draft_id {
+ self.active_entry = Some(ActiveEntry::Draft {
+ id: draft_id,
+ workspace: workspace.clone(),
+ });
+ }
+ }
+
+ fn activate_draft(
+ &mut self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(multi_workspace) = self.multi_workspace.upgrade() {
+ multi_workspace.update(cx, |mw, cx| {
+ mw.activate(workspace.clone(), window, cx);
+ });
+ }
+
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.activate_draft(draft_id, true, window, cx);
+ });
+ }
+ ws.focus_panel::<AgentPanel>(window, cx);
+ });
+
+ self.active_entry = Some(ActiveEntry::Draft {
+ id: draft_id,
+ workspace: workspace.clone(),
+ });
+
+ self.observe_draft_editor(cx);
+ }
+
+ fn remove_draft(
+ &mut self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, _cx| {
+ panel.remove_draft(draft_id);
+ });
+ }
+ });
+
+ let was_active = self
+ .active_entry
+ .as_ref()
+ .is_some_and(|e| e.is_active_draft(draft_id));
+
+ if was_active {
+ let mut switched = false;
+ let group_key = workspace.read(cx).project_group_key(cx);
+
+ // Try the next draft below in the sidebar (smaller ID
+ // since the list is newest-first). Fall back to the one
+ // above (larger ID) if the deleted draft was last.
+ if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ let ids = panel.read(cx).draft_ids();
+ let sibling = ids
+ .iter()
+ .find(|id| id.0 < draft_id.0)
+ .or_else(|| ids.first());
+ if let Some(&sibling_id) = sibling {
+ self.activate_draft(sibling_id, workspace, window, cx);
+ switched = true;
+ }
+ }
+
+ // No sibling draft β try the first thread in the group.
+ if !switched {
+ let first_thread = self.contents.entries.iter().find_map(|entry| {
+ if let ListEntry::Thread(thread) = entry {
+ if let ThreadEntryWorkspace::Open(ws) = &thread.workspace {
+ if ws.read(cx).project_group_key(cx) == group_key {
+ return Some((thread.metadata.clone(), ws.clone()));
+ }
+ }
+ }
+ None
+ });
+ if let Some((metadata, ws)) = first_thread {
+ self.activate_thread(metadata, &ws, false, window, cx);
+ switched = true;
+ }
+ }
+
+ if !switched {
+ self.active_entry = None;
+ }
+ }
+
+ self.update_entries(cx);
+ }
+
+ fn clear_draft(
+ &mut self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.clear_draft_editor(draft_id, window, cx);
});
}
- workspace.focus_panel::<AgentPanel>(window, cx);
});
+ self.update_entries(cx);
+ }
+
+ /// Cleans, collapses whitespace, and truncates raw editor text
+ /// for display as a draft label in the sidebar.
+ fn truncate_draft_label(raw: &str) -> Option<SharedString> {
+ let cleaned = Self::clean_mention_links(raw);
+ let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
+ if text.is_empty() {
+ return None;
+ }
+ const MAX_CHARS: usize = 250;
+ if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
+ text.truncate(truncate_at);
+ }
+ Some(text.into())
+ }
+
+ /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
+ fn read_draft_text(
+ &self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ cx: &App,
+ ) -> Option<SharedString> {
+ let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
+ Self::truncate_draft_label(&raw)
}
fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
@@ -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