Detailed changes
@@ -1099,6 +1099,7 @@ impl From<&AcpThread> for ActionLogTelemetry {
#[derive(Debug)]
pub enum AcpThreadEvent {
+ PromptUpdated,
NewEntry,
TitleUpdated,
TokenUsageUpdated,
@@ -1266,11 +1267,20 @@ impl AcpThread {
&self.available_commands
}
+ pub fn is_draft_thread(&self) -> bool {
+ self.entries().is_empty()
+ }
+
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
self.draft_prompt.as_deref()
}
- pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
+ pub fn set_draft_prompt(
+ &mut self,
+ prompt: Option<Vec<acp::ContentBlock>>,
+ cx: &mut Context<Self>,
+ ) {
+ cx.emit(AcpThreadEvent::PromptUpdated);
self.draft_prompt = prompt;
}
@@ -349,7 +349,7 @@ impl NativeAgent {
prompt_capabilities_rx,
cx,
);
- acp_thread.set_draft_prompt(draft_prompt);
+ acp_thread.set_draft_prompt(draft_prompt, cx);
acp_thread.set_ui_scroll_position(scroll_position);
acp_thread.update_token_usage(token_usage, cx);
acp_thread
@@ -2851,8 +2851,8 @@ mod internal_tests {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new("b.md", uri.to_string())),
acp::ContentBlock::Text(acp::TextContent::new(" please")),
];
- acp_thread.update(cx, |thread, _cx| {
- thread.set_draft_prompt(Some(draft_blocks.clone()));
+ acp_thread.update(cx, |thread, cx| {
+ thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
});
thread.update(cx, |thread, _cx| {
thread.set_ui_scroll_position(Some(gpui::ListOffset {
@@ -2980,8 +2980,8 @@ mod internal_tests {
let draft_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
"unsaved draft",
))];
- acp_thread.update(cx, |thread, _cx| {
- thread.set_draft_prompt(Some(draft_blocks.clone()));
+ acp_thread.update(cx, |thread, cx| {
+ thread.set_draft_prompt(Some(draft_blocks.clone()), cx);
});
// Close the session immediately — no run_until_parked in between.
@@ -221,6 +221,8 @@ impl AgentConnectionStore {
self.entries.retain(|key, _| match key {
Agent::NativeAgent => true,
Agent::Custom { id } => store.external_agents.contains_key(id),
+ #[cfg(any(test, feature = "test-support"))]
+ Agent::Stub => true,
});
cx.notify();
}
@@ -1418,7 +1418,8 @@ impl AgentDiff {
| AcpThreadEvent::Retry(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
- | AcpThreadEvent::WorkingDirectoriesUpdated => {}
+ | AcpThreadEvent::WorkingDirectoriesUpdated
+ | AcpThreadEvent::PromptUpdated => {}
}
}
@@ -8,7 +8,7 @@ use std::{
time::Duration,
};
-use acp_thread::{AcpThread, MentionUri, ThreadStatus};
+use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus};
use agent::{ContextServerRegistry, SharedThread, ThreadStore};
use agent_client_protocol as acp;
use agent_servers::AgentServer;
@@ -30,7 +30,7 @@ use zed_actions::{
};
use crate::DEFAULT_THREAD_TITLE;
-use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree,
Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
@@ -69,7 +69,7 @@ use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
use project::project_settings::ProjectSettings;
use project::trusted_worktrees::{PathTrust, TrustedWorktrees};
-use project::{Project, ProjectPath, Worktree, WorktreePaths, linked_worktree_short_name};
+use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
use prompt_store::{PromptStore, UserPromptId};
use release_channel::ReleaseChannel;
use remote::RemoteConnectionOptions;
@@ -170,11 +170,12 @@ struct SerializedAgentPanel {
selected_agent: Option<Agent>,
#[serde(default)]
last_active_thread: Option<SerializedActiveThread>,
+ draft_thread_prompt: Option<Vec<acp::ContentBlock>>,
}
#[derive(Serialize, Deserialize, Debug)]
struct SerializedActiveThread {
- session_id: String,
+ session_id: Option<String>,
agent_type: Agent,
title: Option<String>,
work_dirs: Option<SerializedPathList>,
@@ -743,6 +744,7 @@ pub struct AgentPanel {
focus_handle: FocusHandle,
base_view: BaseView,
overlay_view: Option<OverlayView>,
+ draft_thread: Option<Entity<ConversationView>>,
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
@@ -765,6 +767,7 @@ pub struct AgentPanel {
_worktree_creation_task: Option<Task<()>>,
show_trust_workspace_message: bool,
_base_view_observation: Option<Subscription>,
+ _draft_editor_observation: Option<Subscription>,
}
impl AgentPanel {
@@ -775,12 +778,14 @@ impl AgentPanel {
let selected_agent = self.selected_agent.clone();
+ let is_draft_active = self.active_thread_is_draft(cx);
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
+
let title = thread.title();
let work_dirs = thread.work_dirs().cloned();
SerializedActiveThread {
- session_id: thread.session_id().0.to_string(),
+ session_id: (!is_draft_active).then(|| thread.session_id().0.to_string()),
agent_type: self.selected_agent.clone(),
title: title.map(|t| t.to_string()),
work_dirs: work_dirs.map(|dirs| dirs.serialize()),
@@ -788,12 +793,25 @@ impl AgentPanel {
});
let kvp = KeyValueStore::global(cx);
+ let draft_thread_prompt = self.draft_thread.as_ref().and_then(|conversation| {
+ Some(
+ conversation
+ .read(cx)
+ .root_thread(cx)?
+ .read(cx)
+ .thread
+ .read(cx)
+ .draft_prompt()?
+ .to_vec(),
+ )
+ });
self.pending_serialization = Some(cx.background_spawn(async move {
save_serialized_panel(
workspace_id,
SerializedAgentPanel {
selected_agent: Some(selected_agent),
last_active_thread,
+ draft_thread_prompt,
},
kvp,
)
@@ -833,28 +851,38 @@ impl AgentPanel {
})
.await;
+ let was_draft_active = serialized_panel
+ .as_ref()
+ .and_then(|p| p.last_active_thread.as_ref())
+ .is_some_and(|t| t.session_id.is_none());
+
let last_active_thread = if let Some(thread_info) = serialized_panel
.as_ref()
.and_then(|p| p.last_active_thread.as_ref())
{
- let session_id = acp::SessionId::new(thread_info.session_id.clone());
- let is_restorable = cx
- .update(|_window, cx| {
- let store = ThreadMetadataStore::global(cx);
- store
- .read(cx)
- .entry_by_session(&session_id)
- .is_some_and(|entry| !entry.archived)
- })
- .unwrap_or(false);
- if is_restorable {
- Some(thread_info)
- } else {
- log::info!(
- "last active thread {} is archived or missing, skipping restoration",
- thread_info.session_id
- );
- None
+ match &thread_info.session_id {
+ Some(session_id_str) => {
+ let session_id = acp::SessionId::new(session_id_str.clone());
+ let is_restorable = cx
+ .update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx);
+ store
+ .read(cx)
+ .entry_by_session(&session_id)
+ .is_some_and(|entry| !entry.archived)
+ })
+ .unwrap_or(false);
+ if is_restorable {
+ Some(thread_info)
+ } else {
+ log::info!(
+ "last active thread {} is archived or missing, skipping restoration",
+ session_id_str
+ );
+ None
+ }
+ }
+ None => None,
}
} else {
None
@@ -886,23 +914,66 @@ impl AgentPanel {
});
if let Some(thread_info) = last_active_thread {
- let agent = thread_info.agent_type.clone();
+ if let Some(session_id_str) = &thread_info.session_id {
+ let agent = thread_info.agent_type.clone();
+ let session_id: acp::SessionId = session_id_str.clone().into();
+ panel.update(cx, |panel, cx| {
+ panel.selected_agent = agent.clone();
+ panel.load_agent_thread(
+ agent,
+ session_id,
+ thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
+ thread_info.title.as_ref().map(|t| t.clone().into()),
+ false,
+ window,
+ cx,
+ );
+ });
+ }
+ }
+
+ let draft_prompt = serialized_panel
+ .as_ref()
+ .and_then(|p| p.draft_thread_prompt.clone());
+
+ if draft_prompt.is_some() || was_draft_active {
panel.update(cx, |panel, cx| {
- panel.selected_agent = agent.clone();
- panel.load_agent_thread(
+ let agent = if panel.project.read(cx).is_via_collab() {
+ Agent::NativeAgent
+ } else {
+ panel.selected_agent.clone()
+ };
+ let initial_content = draft_prompt.map(|blocks| {
+ AgentInitialContent::ContentBlock {
+ blocks,
+ auto_submit: false,
+ }
+ });
+ let thread = panel.create_agent_thread(
agent,
- thread_info.session_id.clone().into(),
- thread_info
- .work_dirs
- .as_ref()
- .map(|dirs| PathList::deserialize(dirs)),
- thread_info.title.as_ref().map(|t| t.clone().into()),
- false,
+ None,
+ None,
+ None,
+ initial_content,
window,
cx,
);
+ panel.draft_thread = Some(thread.conversation_view.clone());
+ panel.observe_draft_editor(&thread.conversation_view, cx);
+
+ if was_draft_active && last_active_thread.is_none() {
+ panel.set_base_view(
+ BaseView::AgentThread {
+ conversation_view: thread.conversation_view,
+ },
+ false,
+ window,
+ cx,
+ );
+ }
});
}
+
panel
})?;
@@ -1081,6 +1152,7 @@ impl AgentPanel {
configuration_subscription: None,
focus_handle: cx.focus_handle(),
context_server_registry,
+ draft_thread: None,
retained_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
@@ -1106,6 +1178,7 @@ impl AgentPanel {
cx,
)),
_base_view_observation: None,
+ _draft_editor_observation: None,
};
// Initial sync of agent servers from extensions
@@ -1210,21 +1283,81 @@ impl AgentPanel {
}
/// Clear the active view, retaining any running thread in the background.
- pub fn clear_base_view(&mut self, cx: &mut Context<Self>) {
+ pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized);
self.retain_running_thread(old_view, cx);
self.clear_overlay_state();
- self._thread_view_subscription = None;
- self._active_thread_focus_subscription = None;
- self._base_view_observation = None;
+ self.activate_draft(false, window, cx);
self.serialize(cx);
cx.emit(AgentPanelEvent::ActiveViewChanged);
cx.notify();
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
- let id = self.create_thread(window, cx);
- self.activate_retained_thread(id, true, window, cx);
+ self.activate_draft(true, window, cx);
+ }
+
+ pub fn activate_draft(&mut self, focus: bool, window: &mut Window, cx: &mut Context<Self>) {
+ let draft = self.ensure_draft(window, cx);
+ if let BaseView::AgentThread { conversation_view } = &self.base_view {
+ if conversation_view.entity_id() == draft.entity_id() {
+ if focus {
+ self.focus_handle(cx).focus(window, cx);
+ }
+ return;
+ }
+ }
+ self.set_base_view(
+ BaseView::AgentThread {
+ conversation_view: draft,
+ },
+ focus,
+ window,
+ cx,
+ );
+ }
+
+ fn ensure_draft(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Entity<ConversationView> {
+ if let Some(draft) = &self.draft_thread {
+ return draft.clone();
+ }
+ let agent = if self.project.read(cx).is_via_collab() {
+ Agent::NativeAgent
+ } else {
+ self.selected_agent.clone()
+ };
+ let thread = self.create_agent_thread(agent, None, None, None, None, window, cx);
+ self.draft_thread = Some(thread.conversation_view.clone());
+ self.observe_draft_editor(&thread.conversation_view, cx);
+ thread.conversation_view
+ }
+
+ fn observe_draft_editor(
+ &mut self,
+ conversation_view: &Entity<ConversationView>,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(acp_thread) = conversation_view.read(cx).root_acp_thread(cx) {
+ self._draft_editor_observation = Some(cx.subscribe(
+ &acp_thread,
+ |this, _, e: &AcpThreadEvent, cx| {
+ if let AcpThreadEvent::PromptUpdated = e {
+ this.serialize(cx);
+ }
+ },
+ ));
+ } else {
+ let cv = conversation_view.clone();
+ self._draft_editor_observation = Some(cx.observe(&cv, |this, cv, cx| {
+ if cv.read(cx).root_acp_thread(cx).is_some() {
+ this.observe_draft_editor(&cv, cx);
+ }
+ }));
+ }
}
pub fn create_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) -> ThreadId {
@@ -1258,18 +1391,24 @@ impl AgentPanel {
);
}
- pub fn remove_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
+ pub fn remove_thread(&mut self, id: ThreadId, window: &mut Window, cx: &mut Context<Self>) {
self.retained_threads.remove(&id);
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.delete(id, cx);
});
- if self.active_thread_id(cx) == Some(id) && self.active_thread_is_draft(cx) {
- self.base_view = BaseView::Uninitialized;
+ if self
+ .draft_thread
+ .as_ref()
+ .is_some_and(|d| d.read(cx).thread_id == id)
+ {
+ self.draft_thread = None;
+ self._draft_editor_observation = None;
+ }
+
+ if self.active_thread_id(cx) == Some(id) {
self.clear_overlay_state();
- self._thread_view_subscription = None;
- self._active_thread_focus_subscription = None;
- self._base_view_observation = None;
+ self.activate_draft(false, window, cx);
self.serialize(cx);
cx.emit(AgentPanelEvent::ActiveViewChanged);
cx.notify();
@@ -1285,40 +1424,6 @@ impl AgentPanel {
}
}
- pub fn draft_thread_ids(&self, cx: &App) -> Vec<ThreadId> {
- let is_draft = |cv: &Entity<ConversationView>| -> bool {
- let cv = cv.read(cx);
- match cv.root_thread(cx) {
- Some(tv) => tv.read(cx).is_draft(cx),
- None => cv.is_new_draft(),
- }
- };
-
- let mut ids: Vec<ThreadId> = self
- .retained_threads
- .iter()
- .filter(|(_, cv)| is_draft(cv))
- .map(|(id, _)| *id)
- .collect();
-
- if let BaseView::AgentThread { conversation_view } = &self.base_view {
- let thread_id = conversation_view.read(cx).thread_id;
- if is_draft(conversation_view) && !ids.contains(&thread_id) {
- ids.push(thread_id);
- }
- }
-
- if let Some(store) = ThreadMetadataStore::try_global(cx) {
- let store = store.read(cx);
- ids.sort_by(|a, b| {
- let a_time = store.entry(*a).and_then(|m| m.created_at);
- let b_time = store.entry(*b).and_then(|m| m.created_at);
- b_time.cmp(&a_time)
- });
- }
- ids
- }
-
pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option<String> {
let cv = self
.retained_threads
@@ -1506,6 +1611,8 @@ impl AgentPanel {
.read(cx)
.entry(&self.selected_agent)
.map_or(false, |entry| entry.read(cx).history().is_some()),
+ #[cfg(any(test, feature = "test-support"))]
+ Agent::Stub => false,
}
}
@@ -2045,6 +2152,14 @@ impl AgentPanel {
return;
};
+ if self
+ .draft_thread
+ .as_ref()
+ .is_some_and(|d| d.entity_id() == conversation_view.entity_id())
+ {
+ return;
+ }
+
let thread_id = conversation_view.read(cx).thread_id;
if self.retained_threads.contains_key(&thread_id) {
@@ -2295,6 +2410,13 @@ impl AgentPanel {
let Some(thread_id) = this.active_thread_id(cx) else {
return;
};
+ if this.draft_thread.as_ref().is_some_and(|d| {
+ this.active_conversation_view()
+ .is_some_and(|active| active.entity_id() == d.entity_id())
+ }) {
+ this.draft_thread = None;
+ this._draft_editor_observation = None;
+ }
this.retained_threads.remove(&thread_id);
cx.emit(AgentPanelEvent::MessageSentOrQueued { thread_id });
}
@@ -2489,37 +2611,6 @@ impl AgentPanel {
.unwrap_or_else(ThreadId::new);
let workspace = self.workspace.clone();
let project = self.project.clone();
- let mut worktree_paths = project.read(cx).worktree_paths(cx);
- if let Some(existing) = &existing_metadata {
- // When resuming a session (e.g. clicking a linked-worktree thread
- // in the sidebar), the current workspace's project may not have
- // completed its git scan yet. At that point `from_project()` would
- // compute main_worktree_paths from the raw folder path instead of
- // the git repo root, overwriting the thread's canonical project
- // group association. Preserve the existing main_worktree_paths so
- // the thread stays in the correct sidebar group.
- if !existing.main_worktree_paths().is_empty() {
- worktree_paths = WorktreePaths::from_path_lists(
- existing.main_worktree_paths().clone(),
- worktree_paths.folder_path_list().clone(),
- )
- .unwrap_or(worktree_paths);
- }
- }
- let remote_connection = project.read(cx).remote_connection_options(cx);
-
- if resume_session_id.is_none() {
- let metadata = ThreadMetadata::new_draft(
- thread_id,
- agent.id(),
- title.clone(),
- worktree_paths,
- remote_connection,
- );
- ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.save(metadata, cx);
- });
- }
if self.selected_agent != agent {
self.selected_agent = agent.clone();
@@ -2586,8 +2677,11 @@ impl AgentPanel {
.is_some_and(|thread| !thread.read(cx).entries().is_empty())
}
- pub fn active_thread_is_draft(&self, cx: &App) -> bool {
- self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
+ pub fn active_thread_is_draft(&self, _cx: &App) -> bool {
+ self.draft_thread.as_ref().is_some_and(|draft| {
+ self.active_conversation_view()
+ .is_some_and(|active| active.entity_id() == draft.entity_id())
+ })
}
// TODO: The mapping from workspace root paths to git repositories needs a
@@ -3666,7 +3760,11 @@ impl Panel for AgentPanel {
});
}
- fn set_active(&mut self, _active: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
+ fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
+ if active {
+ self.ensure_thread_initialized(window, cx);
+ }
+ }
fn remote_id() -> Option<proto::PanelId> {
Some(proto::PanelId::AssistantPanel)
@@ -3707,6 +3805,17 @@ impl Panel for AgentPanel {
}
impl AgentPanel {
+ fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if matches!(self.base_view, BaseView::Uninitialized)
+ && !matches!(
+ self.worktree_creation_status,
+ Some((_, WorktreeCreationStatus::Creating(_)))
+ )
+ {
+ self.activate_draft(false, window, cx);
+ }
+ }
+
fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
let content = match self.visible_surface() {
VisibleSurface::AgentThread(conversation_view) => {
@@ -5025,6 +5134,31 @@ impl AgentPanel {
pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context<Self>) {
self.start_thread_in_menu_handle.hide(cx);
}
+
+ /// Creates a draft thread using a stub server and sets it as the active view.
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn open_draft_with_server(
+ &mut self,
+ server: Rc<dyn AgentServer>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let ext_agent = Agent::Custom {
+ id: server.agent_id(),
+ };
+ let thread = self.create_agent_thread_with_server(
+ ext_agent,
+ Some(server),
+ None,
+ None,
+ None,
+ None,
+ window,
+ cx,
+ );
+ self.draft_thread = Some(thread.conversation_view.clone());
+ self.set_base_view(thread.into(), true, window, cx);
+ }
}
#[cfg(test)]
@@ -5702,6 +5836,186 @@ mod tests {
(session_id, thread_id)
}
+ #[gpui::test]
+ async fn test_draft_promotion_creates_metadata_and_new_session_on_reload(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ cx.update(|cx| {
+ agent::ThreadStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/project", json!({ "file.txt": "" })).await;
+ let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+
+ let multi_workspace =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+ let workspace = multi_workspace
+ .read_with(cx, |mw, _cx| mw.workspace().clone())
+ .unwrap();
+
+ workspace.update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+
+ let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+ let panel = workspace.update_in(cx, |workspace, window, cx| {
+ let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
+ workspace.add_panel(panel.clone(), window, cx);
+ panel
+ });
+
+ // Register a shared stub connection and use Agent::Stub so the draft
+ // (and any reloaded draft) uses it.
+ let stub_connection =
+ crate::test_support::set_stub_agent_connection(StubAgentConnection::new());
+ stub_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Response".into()),
+ )]);
+ panel.update_in(cx, |panel, window, cx| {
+ panel.selected_agent = Agent::Stub;
+ panel.activate_draft(true, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Verify the thread is considered a draft.
+ panel.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_thread_is_draft(cx),
+ "thread should be a draft before any message is sent"
+ );
+ assert!(
+ panel.draft_thread.is_some(),
+ "draft_thread field should be set"
+ );
+ });
+ let draft_session_id = active_session_id(&panel, cx);
+ let thread_id = active_thread_id(&panel, cx);
+
+ // No metadata should exist yet for a draft.
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ assert!(
+ store.entry(thread_id).is_none(),
+ "draft thread should not have metadata in the store"
+ );
+ });
+
+ // Set draft prompt and serialize — the draft should survive a round-trip
+ // with its prompt intact but a fresh ACP session.
+ let draft_prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
+ "Hello from draft",
+ ))];
+ panel.update(cx, |panel, cx| {
+ let thread = panel.active_agent_thread(cx).unwrap();
+ thread.update(cx, |thread, cx| {
+ thread.set_draft_prompt(Some(draft_prompt_blocks.clone()), cx);
+ });
+ panel.serialize(cx);
+ });
+ cx.run_until_parked();
+
+ let async_cx = cx.update(|window, cx| window.to_async(cx));
+ let reloaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
+ .await
+ .expect("panel load with draft should succeed");
+ cx.run_until_parked();
+
+ reloaded_panel.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_thread_is_draft(cx),
+ "reloaded panel should still show the draft as active"
+ );
+ assert!(
+ panel.draft_thread.is_some(),
+ "reloaded panel should have a draft_thread"
+ );
+ });
+
+ let reloaded_session_id = active_session_id(&reloaded_panel, cx);
+ assert_ne!(
+ reloaded_session_id, draft_session_id,
+ "reloaded draft should have a fresh ACP session ID"
+ );
+
+ let restored_text = reloaded_panel.read_with(cx, |panel, cx| {
+ let thread_id = panel.active_thread_id(cx).unwrap();
+ panel.editor_text(thread_id, cx)
+ });
+ assert_eq!(
+ restored_text.as_deref(),
+ Some("Hello from draft"),
+ "draft prompt text should be preserved across serialization"
+ );
+
+ // Send a message on the reloaded panel — this promotes the draft to a real thread.
+ let panel = reloaded_panel;
+ let draft_session_id = reloaded_session_id;
+ let thread_id = active_thread_id(&panel, cx);
+ send_message(&panel, cx);
+
+ // Verify promotion: draft_thread is cleared, metadata exists.
+ panel.read_with(cx, |panel, cx| {
+ assert!(
+ !panel.active_thread_is_draft(cx),
+ "thread should no longer be a draft after sending a message"
+ );
+ assert!(
+ panel.draft_thread.is_none(),
+ "draft_thread should be None after promotion"
+ );
+ assert_eq!(
+ panel.active_thread_id(cx),
+ Some(thread_id),
+ "same thread ID should remain active after promotion"
+ );
+ });
+
+ cx.update(|_window, cx| {
+ let store = ThreadMetadataStore::global(cx).read(cx);
+ let metadata = store
+ .entry(thread_id)
+ .expect("promoted thread should have metadata");
+ assert!(
+ metadata.session_id.is_some(),
+ "promoted thread metadata should have a real session_id"
+ );
+ assert_eq!(
+ metadata.session_id.as_ref().unwrap(),
+ &draft_session_id,
+ "metadata session_id should match the thread's ACP session"
+ );
+ });
+
+ // Serialize the panel, then reload it.
+ panel.update(cx, |panel, cx| panel.serialize(cx));
+ cx.run_until_parked();
+
+ let async_cx = cx.update(|window, cx| window.to_async(cx));
+ let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx)
+ .await
+ .expect("panel load should succeed");
+ cx.run_until_parked();
+
+ // The loaded panel should restore the real thread (not the draft).
+ loaded_panel.read_with(cx, |panel, cx| {
+ let active_id = panel.active_thread_id(cx);
+ assert_eq!(
+ active_id,
+ Some(thread_id),
+ "loaded panel should restore the promoted thread"
+ );
+ assert!(
+ !panel.active_thread_is_draft(cx),
+ "restored thread should not be a draft"
+ );
+ });
+ }
+
async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
@@ -6079,60 +6393,6 @@ mod tests {
});
}
- #[gpui::test]
- async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) {
- init_test(cx);
-
- let fs = FakeFs::new(cx.executor());
- cx.update(|cx| {
- agent::ThreadStore::init_global(cx);
- language_model::LanguageModelRegistry::test(cx);
- <dyn fs::Fs>::set_global(fs.clone(), cx);
- });
-
- fs.insert_tree(
- "/project",
- json!({
- ".git": {},
- "src": {
- "main.rs": "fn main() {}"
- }
- }),
- )
- .await;
-
- let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
-
- let multi_workspace =
- cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-
- let workspace = multi_workspace
- .read_with(cx, |multi_workspace, _cx| {
- multi_workspace.workspace().clone()
- })
- .unwrap();
-
- let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
-
- let panel = workspace.update_in(cx, |workspace, window, cx| {
- let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
- workspace.add_panel(panel.clone(), window, cx);
- panel
- });
-
- cx.run_until_parked();
-
- // set_active no longer creates threads — verify it's a no-op.
- panel.update_in(cx, |panel, window, cx| {
- panel.base_view = BaseView::Uninitialized;
- Panel::set_active(panel, true, window, cx);
- assert!(
- matches!(panel.base_view, BaseView::Uninitialized),
- "set_active should not create a thread"
- );
- });
- }
-
#[test]
fn test_deserialize_agent_variants() {
// PascalCase (legacy AgentType format, persisted in panel state)
@@ -259,6 +259,7 @@ pub struct NewNativeAgentThreadFromSummary {
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
+#[non_exhaustive]
pub enum Agent {
#[default]
#[serde(alias = "NativeAgent", alias = "TextThread")]
@@ -268,6 +269,8 @@ pub enum Agent {
#[serde(rename = "name")]
id: AgentId,
},
+ #[cfg(any(test, feature = "test-support"))]
+ Stub,
}
impl From<AgentId> for Agent {
@@ -285,6 +288,8 @@ impl Agent {
match self {
Self::NativeAgent => agent::ZED_AGENT_ID.clone(),
Self::Custom { id } => id.clone(),
+ #[cfg(any(test, feature = "test-support"))]
+ Self::Stub => "stub".into(),
}
}
@@ -296,6 +301,8 @@ impl Agent {
match self {
Self::NativeAgent => "Zed Agent".into(),
Self::Custom { id, .. } => id.0.clone(),
+ #[cfg(any(test, feature = "test-support"))]
+ Self::Stub => "Stub Agent".into(),
}
}
@@ -303,6 +310,8 @@ impl Agent {
match self {
Self::NativeAgent => None,
Self::Custom { .. } => Some(IconName::Sparkle),
+ #[cfg(any(test, feature = "test-support"))]
+ Self::Stub => None,
}
}
@@ -316,6 +325,8 @@ impl Agent {
Self::Custom { id: name } => {
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
}
+ #[cfg(any(test, feature = "test-support"))]
+ Self::Stub => Rc::new(crate::test_support::StubAgentServer::default_response()),
}
}
}
@@ -285,7 +285,8 @@ impl Conversation {
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
- | AcpThreadEvent::WorkingDirectoriesUpdated => {}
+ | AcpThreadEvent::WorkingDirectoriesUpdated
+ | AcpThreadEvent::PromptUpdated => {}
}
}
});
@@ -408,7 +409,8 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::ModeUpdated(_)
| AcpThreadEvent::ConfigOptionsUpdated(_)
- | AcpThreadEvent::SubagentSpawned(_) => false,
+ | AcpThreadEvent::SubagentSpawned(_)
+ | AcpThreadEvent::PromptUpdated => false,
}
}
@@ -435,9 +437,6 @@ pub struct ConversationView {
notification_subscriptions: HashMap<WindowHandle<AgentNotification>, Vec<Subscription>>,
auth_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
- /// True when this conversation was created as a new draft (no resume
- /// session). False when resuming an existing session from history.
- is_new_draft: bool,
}
impl ConversationView {
@@ -447,10 +446,6 @@ impl ConversationView {
})
}
- pub fn is_new_draft(&self) -> bool {
- self.is_new_draft
- }
-
pub fn active_thread(&self) -> Option<&Entity<ThreadView>> {
match &self.server_state {
ServerState::Connected(connected) => connected.active_view(),
@@ -692,7 +687,6 @@ impl ConversationView {
})
.detach();
- let is_new_draft = resume_session_id.is_none();
let thread_id = thread_id.unwrap_or_else(ThreadId::new);
Self {
@@ -723,7 +717,6 @@ impl ConversationView {
auth_task: None,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
- is_new_draft,
}
}
@@ -1631,6 +1624,9 @@ impl ConversationView {
AcpThreadEvent::WorkingDirectoriesUpdated => {
cx.notify();
}
+ AcpThreadEvent::PromptUpdated => {
+ cx.notify();
+ }
}
cx.notify();
}
@@ -492,8 +492,8 @@ impl ThreadView {
None
};
this.update(cx, |this, cx| {
- this.thread.update(cx, |thread, _cx| {
- thread.set_draft_prompt(draft);
+ this.thread.update(cx, |thread, cx| {
+ thread.set_draft_prompt(draft, cx);
});
this.schedule_save(cx);
})
@@ -6,11 +6,36 @@ use project::AgentId;
use project::Project;
use settings::SettingsStore;
use std::any::Any;
+use std::cell::RefCell;
use std::rc::Rc;
use crate::AgentPanel;
use crate::agent_panel;
+thread_local! {
+ static STUB_AGENT_CONNECTION: RefCell<Option<StubAgentConnection>> = const { RefCell::new(None) };
+}
+
+/// Registers a `StubAgentConnection` that will be used by `Agent::Stub`.
+///
+/// Returns the same connection so callers can hold onto it and control
+/// the stub's behavior (e.g. `connection.set_next_prompt_updates(...)`).
+pub fn set_stub_agent_connection(connection: StubAgentConnection) -> StubAgentConnection {
+ STUB_AGENT_CONNECTION.with(|cell| {
+ *cell.borrow_mut() = Some(connection.clone());
+ });
+ connection
+}
+
+/// Returns the shared `StubAgentConnection` used by `Agent::Stub`,
+/// creating a default one if none was registered.
+pub fn stub_agent_connection() -> StubAgentConnection {
+ STUB_AGENT_CONNECTION.with(|cell| {
+ let mut borrow = cell.borrow_mut();
+ borrow.get_or_insert_with(StubAgentConnection::new).clone()
+ })
+}
+
pub struct StubAgentServer<C> {
connection: C,
agent_id: AgentId,
@@ -275,31 +275,6 @@ pub struct ThreadMetadata {
}
impl ThreadMetadata {
- pub fn new_draft(
- thread_id: ThreadId,
- agent_id: AgentId,
- title: Option<SharedString>,
- worktree_paths: WorktreePaths,
- remote_connection: Option<RemoteConnectionOptions>,
- ) -> Self {
- let now = Utc::now();
- Self {
- thread_id,
- session_id: None,
- agent_id,
- title,
- updated_at: now,
- created_at: Some(now),
- worktree_paths: worktree_paths.clone(),
- remote_connection,
- archived: worktree_paths.is_empty(),
- }
- }
-
- pub fn is_draft(&self) -> bool {
- self.session_id.is_none()
- }
-
pub fn display_title(&self) -> SharedString {
self.title
.clone()
@@ -1140,7 +1115,7 @@ impl ThreadMetadataStore {
};
let thread_ref = thread.read(cx);
- if thread_ref.entries().is_empty() {
+ if thread_ref.is_draft_thread() {
return;
}
@@ -2272,18 +2247,14 @@ mod tests {
let session_id = thread.read_with(&vcx, |t, _| t.session_id().clone());
let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
- // Initial metadata was created by the panel with session_id: None.
+ // Draft threads no longer create metadata entries.
cx.read(|cx| {
let store = ThreadMetadataStore::global(cx).read(cx);
- assert_eq!(store.entry_ids().count(), 1);
- assert!(
- store.entry(thread_id).unwrap().session_id.is_none(),
- "expected initial panel metadata to have no session_id"
- );
+ assert_eq!(store.entry_ids().count(), 0);
});
// Setting a title on an empty thread should be ignored by the
- // event handler (entries are empty), leaving session_id as None.
+ // event handler (entries are empty), so no metadata is created.
thread.update_in(&mut vcx, |thread, _window, cx| {
thread.set_title("Draft Thread".into(), cx).detach();
});
@@ -2291,9 +2262,10 @@ mod tests {
cx.read(|cx| {
let store = ThreadMetadataStore::global(cx).read(cx);
- assert!(
- store.entry(thread_id).unwrap().session_id.is_none(),
- "expected title updates on empty thread to be ignored by event handler"
+ assert_eq!(
+ store.entry_ids().count(),
+ 0,
+ "expected title updates on empty thread to not create metadata"
);
});
@@ -192,7 +192,6 @@ struct ThreadEntry {
is_live: bool,
is_background: bool,
is_title_generating: bool,
- is_draft: bool,
highlight_positions: Vec<usize>,
worktrees: Vec<ThreadItemWorktreeInfo>,
diff_stats: DiffStats,
@@ -377,7 +376,6 @@ pub struct Sidebar {
recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
project_header_menu_ix: Option<usize>,
_subscriptions: Vec<gpui::Subscription>,
- _draft_observations: Vec<gpui::Subscription>,
}
impl Sidebar {
@@ -404,7 +402,6 @@ impl Sidebar {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
this.sync_active_entry_from_active_workspace(cx);
this.replace_archived_panel_thread(window, cx);
- this.observe_draft_editors(cx);
this.update_entries(cx);
}
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -466,7 +463,6 @@ impl Sidebar {
recent_projects_popover_handle: PopoverMenuHandle::default(),
project_header_menu_ix: None,
_subscriptions: Vec::new(),
- _draft_observations: Vec::new(),
}
}
@@ -544,12 +540,10 @@ impl Sidebar {
ProjectEvent::WorktreeAdded(_)
| ProjectEvent::WorktreeRemoved(_)
| ProjectEvent::WorktreeOrderChanged => {
- this.observe_draft_editors(cx);
this.update_entries(cx);
}
ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
this.move_thread_paths(project, old_worktree_paths, cx);
- this.observe_draft_editors(cx);
this.update_entries(cx);
}
_ => {}
@@ -595,7 +589,6 @@ impl Sidebar {
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
self.subscribe_to_agent_panel(&agent_panel, window, cx);
- self.observe_draft_editors(cx);
}
}
@@ -660,7 +653,6 @@ impl Sidebar {
|this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged => {
this.sync_active_entry_from_panel(_agent_panel, cx);
- this.observe_draft_editors(cx);
this.update_entries(cx);
}
AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => {
@@ -795,63 +787,6 @@ impl Sidebar {
}
}
- fn observe_draft_editors(&mut self, cx: &mut Context<Self>) {
- let Some(multi_workspace) = self.multi_workspace.upgrade() else {
- self._draft_observations.clear();
- return;
- };
-
- // Collect conversation views up front to avoid holding a
- // borrow on `cx` across `cx.observe` calls.
- let conversation_views: Vec<_> = multi_workspace
- .read(cx)
- .workspaces()
- .filter_map(|ws| ws.read(cx).panel::<AgentPanel>(cx))
- .flat_map(|panel| panel.read(cx).conversation_views())
- .collect();
-
- let mut subscriptions = Vec::with_capacity(conversation_views.len());
- for cv in conversation_views {
- if let Some(thread_view) = cv.read(cx).active_thread() {
- let editor = thread_view.read(cx).message_editor.clone();
- subscriptions.push(cx.observe(&editor, |this, _editor, cx| {
- this.update_entries(cx);
- }));
- } else {
- subscriptions.push(cx.observe(&cv, |this, _cv, cx| {
- this.observe_draft_editors(cx);
- this.update_entries(cx);
- }));
- }
- }
-
- self._draft_observations = subscriptions;
- }
-
- fn clean_mention_links(input: &str) -> String {
- let mut result = String::with_capacity(input.len());
- let mut remaining = input;
-
- while let Some(start) = remaining.find("[@") {
- result.push_str(&remaining[..start]);
- let after_bracket = &remaining[start + 1..]; // skip '['
- if let Some(close_bracket) = after_bracket.find("](") {
- let mention = &after_bracket[..close_bracket]; // "@something"
- let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
- if let Some(close_paren) = after_link_start.find(')') {
- result.push_str(mention);
- remaining = &after_link_start[close_paren + 1..];
- continue;
- }
- }
- // Couldn't parse full link syntax — emit the literal "[@" and move on.
- result.push_str("[@");
- remaining = &remaining[start + 2..];
- }
- result.push_str(remaining);
- result
- }
-
/// Opens a new workspace for a group that has no open workspaces.
fn open_workspace_for_group(
&mut self,
@@ -989,6 +924,8 @@ impl Sidebar {
let icon = match agent {
Agent::NativeAgent => IconName::ZedAgent,
Agent::Custom { .. } => IconName::Terminal,
+
+ _ => IconName::ZedAgent,
};
let icon_from_external_svg = agent_server_store
.as_ref()
@@ -1091,7 +1028,6 @@ impl Sidebar {
let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
let worktrees =
worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path);
- let is_draft = row.is_draft();
ThreadEntry {
metadata: row,
icon,
@@ -1101,7 +1037,6 @@ impl Sidebar {
is_live: false,
is_background: false,
is_title_generating: false,
- is_draft,
highlight_positions: Vec::new(),
worktrees,
diff_stats: DiffStats::default(),
@@ -1314,26 +1249,6 @@ impl Sidebar {
continue;
}
- {
- // Override titles with editor text for drafts and
- // threads that still have the default placeholder
- // title (panel considers them drafts even if they
- // have a session_id).
- for thread in &mut threads {
- let needs_title_override =
- thread.is_draft || thread.metadata.title.is_none();
- if needs_title_override {
- if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
- if let Some(text) =
- self.read_draft_text(thread.metadata.thread_id, workspace, cx)
- {
- thread.metadata.title = Some(text);
- }
- }
- }
- }
- }
-
let total = threads.len();
let extra_batches = self.group_extra_batches(&group_key, cx);
@@ -1472,19 +1387,31 @@ impl Sidebar {
waiting_thread_count,
is_active: is_active_group,
has_threads,
- } => self.render_project_header(
- ix,
- false,
- key,
- label,
- highlight_positions,
- *has_running_threads,
- *waiting_thread_count,
- *is_active_group,
- is_selected,
- *has_threads,
- cx,
- ),
+ } => {
+ let has_active_draft = is_active
+ && self
+ .active_workspace(cx)
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .is_some_and(|panel| {
+ let panel = panel.read(cx);
+ panel.active_thread_is_draft(cx)
+ || panel.active_conversation_view().is_none()
+ });
+ self.render_project_header(
+ ix,
+ false,
+ key,
+ label,
+ highlight_positions,
+ *has_running_threads,
+ *waiting_thread_count,
+ *is_active_group,
+ is_selected,
+ *has_threads,
+ has_active_draft,
+ cx,
+ )
+ }
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
ListEntry::ViewMore {
key,
@@ -1540,6 +1467,7 @@ impl Sidebar {
is_active: bool,
is_focused: bool,
has_threads: bool,
+ has_active_draft: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let host = key.host();
@@ -1664,7 +1592,7 @@ impl Sidebar {
.child(gradient_overlay())
.child(
h_flex()
- .when(!is_ellipsis_menu_open, |this| {
+ .when(!is_ellipsis_menu_open && !has_active_draft, |this| {
this.visible_on_hover(&group_name)
})
.child(gradient_overlay())
@@ -1682,6 +1610,7 @@ impl Sidebar {
IconName::Plus,
)
.icon_size(IconSize::Small)
+ .when(has_active_draft, |this| this.icon_color(Color::Accent))
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Start New Agent Thread",
@@ -1954,6 +1883,14 @@ impl Sidebar {
let is_focused = self.focus_handle.is_focused(window);
let is_selected = is_focused && self.selection == Some(header_idx);
+ let has_active_draft = *is_active
+ && self
+ .active_workspace(cx)
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .is_some_and(|panel| {
+ let panel = panel.read(cx);
+ panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none()
+ });
let header_element = self.render_project_header(
header_idx,
true,
@@ -1965,6 +1902,7 @@ impl Sidebar {
*is_active,
is_selected,
*has_threads,
+ has_active_draft,
cx,
);
@@ -2374,7 +2312,6 @@ impl Sidebar {
ws.focus_panel::<AgentPanel>(window, cx);
});
self.pending_thread_activation = None;
- self.observe_draft_editors(cx);
} else {
Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
}
@@ -2958,9 +2895,7 @@ impl Sidebar {
.iter()
.chain(self.contents.entries[..pos].iter().rev())
.find_map(|entry| match entry {
- ListEntry::Thread(t)
- if !t.is_draft && t.metadata.session_id.as_ref() != Some(session_id) =>
- {
+ ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => {
let (workspace_paths, project_group_key) = match &t.workspace {
ThreadEntryWorkspace::Open(ws) => (
PathList::new(&ws.read(cx).root_paths(cx)),
@@ -3260,7 +3195,7 @@ impl Sidebar {
});
if panel_shows_archived {
panel.update(cx, |panel, cx| {
- panel.clear_base_view(cx);
+ panel.clear_base_view(window, cx);
});
}
}
@@ -3300,7 +3235,7 @@ impl Sidebar {
if let Some(workspace) = workspace {
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
- panel.clear_base_view(cx);
+ panel.clear_base_view(window, cx);
});
}
}
@@ -3416,13 +3351,6 @@ impl Sidebar {
return;
};
match self.contents.entries.get(ix) {
- Some(ListEntry::Thread(thread)) if thread.is_draft => {
- let draft_id = thread.metadata.thread_id;
- if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
- let workspace = workspace.clone();
- self.remove_draft(draft_id, &workspace, window, cx);
- }
- }
Some(ListEntry::Thread(thread)) => {
match thread.status {
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
@@ -3735,7 +3663,6 @@ impl Sidebar {
let title: SharedString = thread.metadata.display_title();
let metadata = thread.metadata.clone();
let thread_workspace = thread.workspace.clone();
- let is_draft = thread.is_draft;
let is_hovered = self.hovered_thread_index == Some(ix);
let is_selected = is_active;
@@ -3746,7 +3673,6 @@ impl Sidebar {
let thread_id_for_actions = thread.metadata.thread_id;
let session_id_for_delete = thread.metadata.session_id.clone();
- let thread_workspace_for_dismiss = thread.workspace.clone();
let focus_handle = self.focus_handle.clone();
let id = SharedString::from(format!("thread-entry-{}", ix));
@@ -3804,36 +3730,7 @@ impl Sidebar {
}),
)
})
- .when(is_hovered && !is_running && is_draft, |this| {
- this.action_slot(
- div()
- .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
- cx.stop_propagation();
- })
- .child(
- IconButton::new("close-draft", IconName::Close)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text("Remove Draft"))
- .on_click({
- let thread_workspace = thread_workspace_for_dismiss.clone();
- cx.listener(move |this, _, window, cx| {
- if let ThreadEntryWorkspace::Open(workspace) =
- &thread_workspace
- {
- this.remove_draft(
- thread_id_for_actions,
- workspace,
- window,
- cx,
- );
- }
- })
- }),
- ),
- )
- })
- .when(is_hovered && !is_running && !is_draft, |this| {
+ .when(is_hovered && !is_running, |this| {
this.action_slot(
IconButton::new("archive-thread", IconName::Archive)
.icon_size(IconSize::Small)
@@ -4018,19 +3915,11 @@ impl Sidebar {
let draft_id = workspace.update(cx, |workspace, cx| {
let panel = workspace.panel::<AgentPanel>(cx)?;
let draft_id = panel.update(cx, |panel, cx| {
- if let Some(id) = panel.draft_thread_ids(cx).first().copied() {
- if panel.active_thread_id(cx) != Some(id) {
- panel.activate_retained_thread(id, true, window, cx);
- }
- id
- } else {
- let id = panel.create_thread(window, cx);
- panel.activate_retained_thread(id, true, window, cx);
- id
- }
+ panel.activate_draft(true, window, cx);
+ panel.active_thread_id(cx)
});
workspace.focus_panel::<AgentPanel>(window, cx);
- Some(draft_id)
+ draft_id
});
if let Some(draft_id) = draft_id {
@@ -4042,80 +3931,6 @@ impl Sidebar {
}
}
- fn remove_draft(
- &mut self,
- draft_id: ThreadId,
- 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_thread(draft_id, cx);
- });
- }
- });
-
- let was_active = self
- .active_entry
- .as_ref()
- .is_some_and(|e| e.is_active_thread(&draft_id));
-
- if was_active {
- let group_key = workspace.read(cx).project_group_key(cx);
-
- // Find any remaining thread in the same group.
- let next = self.contents.entries.iter().find_map(|entry| {
- if let ListEntry::Thread(thread) = entry {
- if thread.metadata.thread_id != draft_id {
- 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)) = next {
- self.activate_thread(metadata, &ws, false, window, cx);
- } else {
- self.active_entry = None;
- }
- }
-
- 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 first_line = raw.lines().next().unwrap_or("");
- let cleaned = Self::clean_mention_links(first_line);
- 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: ThreadId,
- workspace: &Entity<Workspace>,
- cx: &App,
- ) -> Option<SharedString> {
- let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
- let raw = panel.read(cx).editor_text(draft_id, cx)?;
- Self::truncate_draft_label(&raw)
- }
-
fn selected_group_key(&self) -> Option<ProjectGroupKey> {
let ix = self.selection?;
match self.contents.entries.get(ix) {
@@ -138,7 +138,6 @@ fn assert_remote_project_integration_sidebar_state(
{
saw_remote_thread = true;
}
- ListEntry::Thread(thread) if thread.is_draft => {}
ListEntry::Thread(thread) => {
let title = thread.metadata.display_title();
panic!(
@@ -409,14 +408,7 @@ fn visible_entries_as_strings(
let title = thread.metadata.display_title();
let worktree = format_linked_worktree_chips(&thread.worktrees);
- if thread.is_draft {
- 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}")
- } else {
+ {
let live = if thread.is_live { " *" } else { "" };
let status_str = match thread.status {
AgentThreadStatus::Running => " (running)",
@@ -523,44 +515,6 @@ async fn test_restore_serialized_archive_view_does_not_panic(cx: &mut TestAppCon
});
}
-#[test]
-fn test_clean_mention_links() {
- // Simple mention link
- assert_eq!(
- Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
- "check @Button.tsx"
- );
-
- // Multiple mention links
- assert_eq!(
- Sidebar::clean_mention_links(
- "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
- ),
- "look at @foo.rs and @bar.rs"
- );
-
- // No mention links — passthrough
- assert_eq!(
- Sidebar::clean_mention_links("plain text with no mentions"),
- "plain text with no mentions"
- );
-
- // Incomplete link syntax — preserved as-is
- assert_eq!(
- Sidebar::clean_mention_links("broken [@mention without closing"),
- "broken [@mention without closing"
- );
-
- // Regular markdown link (no @) — not touched
- assert_eq!(
- Sidebar::clean_mention_links("see [docs](https://example.com)"),
- "see [docs](https://example.com)"
- );
-
- // Empty input
- assert_eq!(Sidebar::clean_mention_links(""), "");
-}
-
#[gpui::test]
async fn test_entities_released_on_window_close(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
@@ -936,7 +890,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
is_live: false,
is_background: false,
is_title_generating: false,
- is_draft: false,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
@@ -961,7 +914,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
is_live: true,
is_background: false,
is_title_generating: false,
- is_draft: false,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
@@ -986,7 +938,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
is_live: true,
is_background: false,
is_title_generating: false,
- is_draft: false,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
@@ -1012,7 +963,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
is_live: false,
is_background: false,
is_title_generating: false,
- is_draft: false,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
@@ -1038,7 +988,6 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
is_live: true,
is_background: true,
is_title_generating: false,
- is_draft: false,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
@@ -2926,219 +2875,11 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
);
});
}
-#[gpui::test]
-async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
- 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 new thread (activates the draft as base view and connects).
- let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
- let connection = StubAgentConnection::new();
- open_thread_with_connection(&panel, connection, cx);
- cx.run_until_parked();
-
- // Type into the draft's message editor.
- let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
- let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
- message_editor.update_in(cx, |editor, window, cx| {
- editor.set_text("Fix the login bug", window, cx);
- });
- cx.run_until_parked();
-
- // The sidebar draft title should now reflect the editor text.
- let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
- sidebar
- .contents
- .entries
- .iter()
- .find_map(|entry| match entry {
- ListEntry::Thread(thread) if thread.is_draft => {
- Some(thread.metadata.display_title())
- }
- _ => None,
- })
- .expect("should still have a draft entry")
- });
- assert_eq!(
- draft_title.as_ref(),
- "Fix the login bug",
- "draft title should update to match editor text"
- );
-}
-
-#[gpui::test]
-async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
- 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);
-
- // Add a second project group.
- let fs = cx.update(|_, 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, ["/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();
-
- // Open a thread in each group's panel to get Connected state.
- let workspace_a =
- multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
- let panel_a = workspace_a.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
-
- let connection_a = StubAgentConnection::new();
- open_thread_with_connection(&panel_a, connection_a, cx);
- cx.run_until_parked();
-
- let connection_b = StubAgentConnection::new();
- open_thread_with_connection(&panel_b, connection_b, cx);
- cx.run_until_parked();
-
- // Type into group A's draft editor.
- let thread_view_a = panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
- let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone());
- editor_a.update_in(cx, |editor, window, cx| {
- editor.set_text("Fix the login bug", window, cx);
- });
- cx.run_until_parked();
-
- // Type into group B's draft editor.
- let thread_view_b = panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
- let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone());
- editor_b.update_in(cx, |editor, window, cx| {
- editor.set_text("Refactor the database", window, cx);
- });
- cx.run_until_parked();
-
- // Both draft titles should reflect their respective editor text.
- let draft_titles: Vec<SharedString> = sidebar.read_with(cx, |sidebar, _cx| {
- sidebar
- .contents
- .entries
- .iter()
- .filter_map(|entry| match entry {
- ListEntry::Thread(thread) if thread.is_draft => {
- Some(thread.metadata.display_title())
- }
- _ => None,
- })
- .collect()
- });
- assert_eq!(draft_titles.len(), 2, "should still have two drafts");
- assert!(
- draft_titles.contains(&SharedString::from("Fix the login bug")),
- "group A draft should show editor text, got: {:?}",
- draft_titles
- );
- assert!(
- draft_titles.contains(&SharedString::from("Refactor the database")),
- "group B draft should show editor text, got: {:?}",
- draft_titles
- );
-}
-
-#[gpui::test]
-async fn test_draft_title_survives_folder_addition(cx: &mut TestAppContext) {
- // When a folder is added to the project, the group key changes.
- // The draft's editor observation should still work and the title
- // should update when the user types.
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
- .await;
- fs.insert_tree("/project-b", serde_json::json!({ "lib": {} }))
- .await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- let project = project::Project::test(fs.clone(), [Path::new("/project-a")], 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 thread with a connection (has a session_id, considered
- // a draft by the panel until messages are sent).
- let connection = StubAgentConnection::new();
- open_thread_with_connection(&panel, connection, cx);
- cx.run_until_parked();
-
- // Type into the editor.
- let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap());
- let editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone());
- editor.update_in(cx, |editor, window, cx| {
- editor.set_text("Initial text", window, cx);
- });
- let thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
- cx.run_until_parked();
-
- // The thread without a title should show the editor text via
- // the draft title override.
- sidebar.read_with(cx, |sidebar, _cx| {
- let thread = sidebar
- .contents
- .entries
- .iter()
- .find_map(|entry| match entry {
- ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
- _ => None,
- });
- assert_eq!(
- thread.and_then(|t| t.metadata.title.as_ref().map(|s| s.as_ref())),
- Some("Initial text"),
- "draft title should show editor text before folder add"
- );
- });
-
- // Add a second folder to the project — this changes the group key.
- project
- .update(cx, |project, cx| {
- project.find_or_create_worktree("/project-b", true, cx)
- })
- .await
- .expect("should add worktree");
- cx.run_until_parked();
-
- // Update editor text.
- editor.update_in(cx, |editor, window, cx| {
- editor.set_text("Updated after folder add", window, cx);
- });
- cx.run_until_parked();
-
- // The draft title should still update. After adding a folder the
- // group key changes, so the thread may not appear in the sidebar
- // if its metadata was saved under the old path list. If it IS
- // found, verify the title was overridden.
- sidebar.read_with(cx, |sidebar, _cx| {
- let thread = sidebar
- .contents
- .entries
- .iter()
- .find_map(|entry| match entry {
- ListEntry::Thread(t) if t.metadata.thread_id == thread_id => Some(t),
- _ => None,
- });
- if let Some(thread) = thread {
- assert_eq!(
- thread.metadata.title.as_ref().map(|s| s.as_ref()),
- Some("Updated after folder add"),
- "draft title should update even after adding a folder"
- );
- }
- });
-}
-
#[gpui::test]
async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
// When the user presses Cmd-N (NewThread action) while viewing a
- // non-empty thread, the sidebar should show the "New Thread" entry.
- // This exercises the same code path as the workspace action handler
- // (which bypasses the sidebar's create_new_thread method).
+ // non-empty thread, the panel should switch to the draft thread.
+ // Drafts are not shown as sidebar rows.
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));
@@ -3175,140 +2916,25 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
});
cx.run_until_parked();
+ // Drafts are not shown as sidebar rows, so entries stay the same.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [~ Draft] *", " Hello *"],
- "After Cmd-N the sidebar should show a highlighted Draft entry"
+ vec!["v [my-project]", " Hello *"],
+ "After Cmd-N the sidebar should not show a Draft entry"
);
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_active_draft(
- sidebar,
- &workspace,
- "active_entry should be Draft after Cmd-N",
+ // The panel should be on the draft and active_entry should track it.
+ panel.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_thread_is_draft(cx),
+ "panel should be showing the draft after Cmd-N",
);
});
-}
-
-#[gpui::test]
-async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) {
- 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 workspace has history.
- 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 saved_session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&saved_session_id, &project, cx).await;
- cx.run_until_parked();
-
- assert_eq!(
- visible_entries_as_strings(&sidebar, cx),
- vec![
- //
- "v [my-project]",
- " Hello *",
- ]
- );
-
- // 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] *", " Hello *"],
- );
-
- let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
sidebar.read_with(cx, |sidebar, _cx| {
assert_active_draft(
sidebar,
&workspace,
- "Draft with server session should be Draft, not Thread",
- );
- });
-}
-
-#[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 thread_id = panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap());
- panel.update_in(cx, |panel, _window, cx| {
- panel.remove_thread(thread_id, cx);
- });
- 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",
+ "active_entry should be Draft after Cmd-N",
);
});
}
@@ -3316,8 +2942,8 @@ async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext)
#[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
- // should still show the "New Thread" entry under the main repo's
- // header and highlight it as active.
+ // should activate the draft thread in the panel. Drafts are not
+ // shown as sidebar rows.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
ThreadStore::init_global(cx);
@@ -3411,18 +3037,24 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
});
cx.run_until_parked();
+ // Drafts are not shown as sidebar rows, so entries stay the same.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
vec![
//
"v [project]",
- " [~ 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"
+ "After Cmd-N the sidebar should not show a Draft entry"
);
+ // The panel should be on the draft and active_entry should track it.
+ worktree_panel.read_with(cx, |panel, cx| {
+ assert!(
+ panel.active_thread_is_draft(cx),
+ "panel should be showing the draft after Cmd-N",
+ );
+ });
sidebar.read_with(cx, |sidebar, _cx| {
assert_active_draft(
sidebar,
@@ -4298,7 +3930,6 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
{
saw_expected_thread = true;
}
- ListEntry::Thread(thread) if thread.is_draft => {}
ListEntry::Thread(thread) => {
let title = thread.metadata.display_title();
let worktree_name = thread
@@ -6462,11 +6093,13 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
});
cx.run_until_parked();
- // Archiving the active thread clears active_entry (no draft is created).
+ // Archiving the active thread activates a draft on the same workspace
+ // (via clear_base_view → activate_draft). The draft is not shown as a
+ // sidebar row but active_entry tracks it.
sidebar.read_with(cx, |sidebar, _| {
assert!(
- sidebar.active_entry.is_none(),
- "expected None after archiving active thread, got: {:?}",
+ matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
+ "expected draft on workspace_b after archiving active thread, got: {:?}",
sidebar.active_entry,
);
});
@@ -6929,8 +6562,6 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d
});
let immediate_active_thread_id =
panel_b_before_settle.read_with(cx, |panel, cx| panel.active_thread_id(cx));
- let immediate_draft_ids =
- panel_b_before_settle.read_with(cx, |panel, cx| panel.draft_thread_ids(cx));
cx.run_until_parked();
@@ -6954,7 +6585,7 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d
);
assert!(
immediate_active_thread_id.is_none() || immediate_active_thread_id == Some(thread_id),
- "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}, draft_ids={immediate_draft_ids:?}"
+ "expected immediate panel state to be either still loading or already on the restored thread, got active_thread_id={immediate_active_thread_id:?}"
);
let entries = visible_entries_as_strings(&sidebar, cx);
@@ -7197,7 +6828,7 @@ async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut
"expected unarchive to reuse the original thread id instead of creating a duplicate row"
);
assert!(
- !session_entries[0].is_draft(),
+ session_entries[0].session_id.is_some(),
"expected restored metadata to be a real thread, got: {:?}",
session_entries[0]
);
@@ -7223,8 +6854,8 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
cx: &mut TestAppContext,
) {
// When a thread is archived while the user is in a different workspace,
- // the group is left empty (no draft is created). Switching back to that
- // workspace should show no active entry.
+ // clear_base_view creates a draft on the archived workspace's panel.
+ // Switching back to that workspace shows the draft as active_entry.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
ThreadStore::init_global(cx);
@@ -7269,8 +6900,9 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
});
cx.run_until_parked();
- // Switch back to project-a. Its panel was cleared during archiving,
- // so active_entry should be None (no draft is created).
+ // Switch back to project-a. Its panel was cleared during archiving
+ // (clear_base_view activated a draft), so active_entry should point
+ // to the draft on workspace_a.
let workspace_a =
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
multi_workspace.update_in(cx, |mw, window, cx| {
@@ -7284,10 +6916,10 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
cx.run_until_parked();
sidebar.read_with(cx, |sidebar, _| {
- assert!(
- sidebar.active_entry.is_none(),
- "expected no active entry after switching to workspace with archived thread, got: {:?}",
- sidebar.active_entry,
+ assert_active_draft(
+ sidebar,
+ &workspace_a,
+ "after switching to workspace with archived thread, active_entry should be the draft",
);
});
}
@@ -7974,13 +7606,11 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
);
}
+// TODO: Restore this test once linked worktree draft entries are re-implemented.
+// The draft-in-sidebar approach was reverted in favor of just the + button toggle.
#[gpui::test]
+#[ignore = "linked worktree draft entries not yet implemented"]
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
- // 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());
@@ -8083,51 +7713,8 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
);
// Find the draft Thread entry whose workspace is the linked worktree.
- let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
- sidebar
- .contents
- .entries
- .iter()
- .position(|entry| match entry {
- ListEntry::Thread(thread) if thread.is_draft => matches!(
- &thread.workspace,
- ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
- ),
- _ => false,
- })
- .expect("expected a draft thread entry for the linked worktree")
- });
-
- assert_eq!(
- multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
- 2
- );
-
- sidebar.update_in(cx, |sidebar, window, cx| {
- sidebar.selection = Some(new_thread_ix);
- sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
- });
- cx.run_until_parked();
-
- assert_eq!(
- multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
- 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| match entry {
- ListEntry::Thread(thread) if thread.is_draft => matches!(
- &thread.workspace,
- ThreadEntryWorkspace::Open(ws) if ws.entity_id() == worktree_ws_id
- ),
- _ => false,
- })
- });
- assert!(
- !has_draft_for_worktree,
- "draft thread entry for the linked worktree should be removed after dismiss"
- );
+ let _ = (worktree_ws_id, sidebar, multi_workspace);
+ // todo("re-implement once linked worktree draft entries exist");
}
#[gpui::test]
@@ -8812,53 +8399,6 @@ async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext)
);
}
-#[gpui::test]
-async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
- // Clicking the + button when an empty draft already exists should
- // focus the existing draft rather than creating a new one.
- 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: no drafts from reconciliation.
- let entries = visible_entries_as_strings(&sidebar, cx);
- let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
- assert_eq!(draft_count, 0, "should start with 0 drafts");
-
- 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: should create a 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 should create a draft");
-
- // Second + click with empty draft: should reuse it, not create a new one.
- 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,
- "second + click should reuse the existing empty draft, not create a new one"
- );
-
- // The draft should be active.
- 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)
@@ -9905,8 +9445,8 @@ mod property_test {
.unwrap_or_default();
// Main code path queries (run for all groups, even without workspaces).
- // Skip drafts (session_id: None) — they are shown via the
- // panel's draft_thread_ids, not by session_id matching.
+ // Skip drafts (session_id: None) — they are not shown in the
+ // sidebar entries.
for metadata in thread_store
.read(cx)
.entries_for_main_worktree_path(&path_list, None)
@@ -10042,7 +9582,15 @@ mod property_test {
}
// 4. Exactly one entry in sidebar contents must be uniquely
- // identified by the active_entry.
+ // identified by the active_entry — unless the panel is showing
+ // a draft, which is represented by the + button's active state
+ // rather than a sidebar row.
+ // TODO: Make this check more complete
+ let is_draft = panel.read(cx).active_thread_is_draft(cx)
+ || panel.read(cx).active_conversation_view().is_none();
+ if is_draft {
+ return Ok(());
+ }
let matching_count = sidebar
.contents
.entries
@@ -10056,8 +9604,8 @@ mod property_test {
.iter()
.filter_map(|e| match e {
ListEntry::Thread(t) => Some(format!(
- "tid={:?} sid={:?} draft={}",
- t.metadata.thread_id, t.metadata.session_id, t.is_draft
+ "tid={:?} sid={:?}",
+ t.metadata.thread_id, t.metadata.session_id
)),
_ => None,
})