From 2ea5644a816f6ec103a391c508c07038c9a92c81 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 15 Apr 2026 03:08:01 -0700 Subject: [PATCH] Simplify draft threads (#53940) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- crates/acp_thread/src/acp_thread.rs | 12 +- crates/agent/src/agent.rs | 10 +- crates/agent_ui/src/agent_connection_store.rs | 2 + crates/agent_ui/src/agent_diff.rs | 3 +- crates/agent_ui/src/agent_panel.rs | 594 +++++++++++++----- crates/agent_ui/src/agent_ui.rs | 11 + crates/agent_ui/src/conversation_view.rs | 18 +- .../src/conversation_view/thread_view.rs | 4 +- crates/agent_ui/src/test_support.rs | 25 + crates/agent_ui/src/thread_metadata_store.rs | 44 +- crates/sidebar/src/sidebar.rs | 277 ++------ crates/sidebar/src/sidebar_tests.rs | 566 ++--------------- 12 files changed, 603 insertions(+), 963 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0c363d9aefda133fd3c87e235e1c0d4a14b11282..279ec6bf66802af72a68fe5202ea9684287217f2 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -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>) { + pub fn set_draft_prompt( + &mut self, + prompt: Option>, + cx: &mut Context, + ) { + cx.emit(AcpThreadEvent::PromptUpdated); self.draft_prompt = prompt; } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 33098cca24fb2a12ad78904b9adade884123209a..143f74bfa90a1bb686bc88dc9942816ca42510ee 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -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. diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index f19a2aa2626d4cbf1fc1ddd0878c5c029d403818..d903a6435d87690b7ffd5f06b89b57c680930a36 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -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(); } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 567595143a41e71a25237e3b1bdcf2301880bccb..502973909704f3f95a7316933045f167ddd08b50 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1418,7 +1418,8 @@ impl AgentDiff { | AcpThreadEvent::Retry(_) | AcpThreadEvent::ModeUpdated(_) | AcpThreadEvent::ConfigOptionsUpdated(_) - | AcpThreadEvent::WorkingDirectoriesUpdated => {} + | AcpThreadEvent::WorkingDirectoriesUpdated + | AcpThreadEvent::PromptUpdated => {} } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 58d547cb7ff165d0b77576d220399dc0f57f903c..184f5c1ee9adbb654fa31235c369e3f92755a0a6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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, #[serde(default)] last_active_thread: Option, + draft_thread_prompt: Option>, } #[derive(Serialize, Deserialize, Debug)] struct SerializedActiveThread { - session_id: String, + session_id: Option, agent_type: Agent, title: Option, work_dirs: Option, @@ -743,6 +744,7 @@ pub struct AgentPanel { focus_handle: FocusHandle, base_view: BaseView, overlay_view: Option, + draft_thread: Option>, retained_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, start_thread_in_menu_handle: PopoverMenuHandle, @@ -765,6 +767,7 @@ pub struct AgentPanel { _worktree_creation_task: Option>, show_trust_workspace_message: bool, _base_view_observation: Option, + _draft_editor_observation: Option, } 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) { + pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context) { 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) { - 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) { + 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, + ) -> Entity { + 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, + cx: &mut Context, + ) { + 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) -> ThreadId { @@ -1258,18 +1391,24 @@ impl AgentPanel { ); } - pub fn remove_thread(&mut self, id: ThreadId, cx: &mut Context) { + pub fn remove_thread(&mut self, id: ThreadId, window: &mut Window, cx: &mut Context) { 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 { - let is_draft = |cv: &Entity| -> 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 = 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 { 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) {} + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active { + self.ensure_thread_initialized(window, cx); + } + } fn remote_id() -> Option { 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) { + 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) -> 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.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, + window: &mut Window, + cx: &mut Context, + ) { + 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, 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); - ::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) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ba68ea093bf51ab4061001c86b30fa99bb2172a7..5d281a5071b561d3dc7f38d89263ce697807f5c0 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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 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()), } } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index d40749a0a2302e4851f4b3c4e4a91563d86a4ef8..63d0d08c2d8cb4a730cdb0100f98e6667872a9c6 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -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, Vec>, auth_task: Option>, _subscriptions: Vec, - /// 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> { 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(); } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 68b23e4270d96710c2dfaaa5755120239529d8f7..1be0c0908da170e794ee495c4c4ef6ff0924b5ac 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -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); }) diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index d9a7c7e207435122f6179cb4db1a7d89ec19e4c2..a141121dda14320c8d1f8039ee2e4a192121e092 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -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> = 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 { connection: C, agent_id: AgentId, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 44288fce95387ee39c02faec0f1d3374698cc279..234eb6221fa1cd62e2e864e657868dc513fc9299 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -275,31 +275,6 @@ pub struct ThreadMetadata { } impl ThreadMetadata { - pub fn new_draft( - thread_id: ThreadId, - agent_id: AgentId, - title: Option, - worktree_paths: WorktreePaths, - remote_connection: Option, - ) -> 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" ); }); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 1995af4cb4d2516e87deaf43c419f8fb2335595e..0a00102fcc3dcbcfa1139c40486c961e04b6b50a 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -192,7 +192,6 @@ struct ThreadEntry { is_live: bool, is_background: bool, is_title_generating: bool, - is_draft: bool, highlight_positions: Vec, worktrees: Vec, diff_stats: DiffStats, @@ -377,7 +376,6 @@ pub struct Sidebar { recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_ix: Option, _subscriptions: Vec, - _draft_observations: Vec, } 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::(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) { - 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::(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::(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, ) -> 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::(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::(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::(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::(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::(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, - window: &mut Window, - cx: &mut Context, - ) { - workspace.update(cx, |ws, cx| { - if let Some(panel) = ws.panel::(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 { - let first_line = raw.lines().next().unwrap_or(""); - let cleaned = Self::clean_mention_links(first_line); - let mut text: String = cleaned.split_whitespace().collect::>().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, - cx: &App, - ) -> Option { - let panel = workspace.read(cx).panel::(cx)?; - let raw = panel.read(cx).editor_text(draft_id, cx)?; - Self::truncate_draft_label(&raw) - } - fn selected_group_key(&self) -> Option { let ix = self.selection?; match self.contents.entries.get(ix) { diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index fe12a58be4fb6381dddd18b1ddde95f33eb67b9d..a174d7f316e195076854fe2f80d94512c2f81177 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -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::(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| ::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::(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 = 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| ::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.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, })