diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0f738ee840d5a50acd04ba977fcdde4c995076fb..8005445034d0b9339d36cb2d48da516f9c2a9207 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1322,11 +1322,6 @@ impl AgentPanel { self.activate_draft(id, true, window, cx); } - pub fn new_empty_thread(&mut self, window: &mut Window, cx: &mut Context) { - self.reset_start_thread_in_to_default(cx); - self.external_thread(None, None, None, None, None, true, window, cx); - } - /// Creates a new empty draft thread and stores it. Returns the DraftId. /// The draft is NOT activated — call `activate_draft` to show it. pub fn create_draft(&mut self, window: &mut Window, cx: &mut Context) -> DraftId { @@ -1401,6 +1396,20 @@ impl AgentPanel { } } + /// Clears the message editor text of a tracked draft. + pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context) { + let Some(cv) = self.draft_threads.get(&id) else { + return; + }; + let Some(tv) = cv.read(cx).active_thread() else { + return; + }; + let editor = tv.read(cx).message_editor.clone(); + editor.update(cx, |editor, cx| { + editor.clear(window, cx); + }); + } + fn take_active_draft_initial_content( &mut self, cx: &mut Context, @@ -2293,6 +2302,12 @@ impl AgentPanel { this.handle_first_send_requested(view.clone(), content.clone(), window, cx); } AcpThreadViewEvent::MessageSentOrQueued => { + // When a draft sends its first message it becomes a + // real thread. Remove it from `draft_threads` so the + // sidebar stops showing a stale draft entry. + if let Some(draft_id) = this.active_draft_id() { + this.draft_threads.remove(&draft_id); + } let session_id = view.read(cx).thread.read(cx).session_id().clone(); cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id }); } @@ -3556,8 +3571,8 @@ impl Panel for AgentPanel { Some((_, WorktreeCreationStatus::Creating)) ) { - let selected_agent = self.selected_agent.clone(); - self.new_agent_thread_inner(selected_agent, false, window, cx); + let id = self.create_draft(window, cx); + self.activate_draft(id, false, window, cx); } } diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index 4398a2154d4abd550535b247ab1a9e518f84b39d..723e6779a44eecb9b4ef05049f392785f84041b1 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -139,16 +139,6 @@ pub fn build_root_plan( .then_some((snapshot, repo)) }); - let matching_worktree_snapshot = workspaces.iter().find_map(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .visible_worktrees(cx) - .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path()) - .map(|worktree| worktree.read(cx).snapshot()) - }); - let (main_repo_path, worktree_repo, branch_name) = if let Some((linked_snapshot, repo)) = linked_repo { ( @@ -160,12 +150,11 @@ pub fn build_root_plan( .map(|branch| branch.name().to_string()), ) } else { - let main_repo_path = matching_worktree_snapshot - .as_ref()? - .root_repo_common_dir() - .and_then(|dir| dir.parent())? - .to_path_buf(); - (main_repo_path, None, None) + // Not a linked worktree — nothing to archive from disk. + // `remove_root` would try to remove the main worktree from + // the project and then run `git worktree remove`, both of + // which fail for main working trees. + return None; }; Some(RootPlan { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b82ee298b0a640d022372933d8ef7160b53fd46f..50e613c2ce9186009059a27667d04fa139b9da09 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -122,9 +122,7 @@ enum ActiveEntry { workspace: Entity, }, Draft { - /// `None` for untracked drafts (e.g., from Cmd-N keyboard shortcut - /// that goes directly through the AgentPanel). - id: Option, + id: DraftId, workspace: Entity, }, } @@ -142,7 +140,7 @@ impl ActiveEntry { } fn is_active_draft(&self, draft_id: DraftId) -> bool { - matches!(self, ActiveEntry::Draft { id: Some(id), .. } if *id == draft_id) + matches!(self, ActiveEntry::Draft { id, .. } if *id == draft_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { @@ -151,23 +149,12 @@ impl ActiveEntry { thread.metadata.session_id == *session_id } ( - ActiveEntry::Draft { - id, - workspace: active_ws, - }, + ActiveEntry::Draft { id, .. }, ListEntry::DraftThread { - draft_id, - workspace: entry_ws, + draft_id: Some(entry_id), .. }, - ) => match (id, draft_id) { - // Both have DraftIds — compare directly. - (Some(active_id), Some(entry_id)) => *active_id == *entry_id, - // Both untracked — match by workspace identity. - (None, None) => entry_ws.as_ref().is_some_and(|ws| ws == active_ws), - // Mixed tracked/untracked — never match. - _ => false, - }, + ) => *id == *entry_id, _ => false, } } @@ -691,8 +678,6 @@ impl Sidebar { window, |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { AgentPanelEvent::ActiveViewChanged => { - // active_entry is fully derived during - // rebuild_contents — just trigger a rebuild. this.observe_draft_editor(cx); this.update_entries(cx); } @@ -812,6 +797,42 @@ impl Sidebar { .detach_and_log_err(cx); } + fn open_workspace_and_create_draft( + &mut self, + project_group_key: &ProjectGroupKey, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let path_list = project_group_key.path_list().clone(); + let host = project_group_key.host(); + let provisional_key = Some(project_group_key.clone()); + let active_workspace = multi_workspace.read(cx).workspace().clone(); + + let task = multi_workspace.update(cx, |this, cx| { + this.find_or_create_workspace( + path_list, + host, + provisional_key, + |options, window, cx| connect_remote(active_workspace, options, window, cx), + window, + cx, + ) + }); + + cx.spawn_in(window, async move |this, cx| { + let workspace = task.await?; + this.update_in(cx, |this, window, cx| { + this.create_new_thread(&workspace, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + /// Rebuilds the sidebar contents from current workspace and thread state. /// /// Iterates [`MultiWorkspace::project_group_keys`] to determine project @@ -842,56 +863,21 @@ impl Sidebar { let query = self.filter_editor.read(cx).text(cx); // Derive active_entry from the active workspace's agent panel. - // Draft is checked first because a conversation can have a session_id - // before any messages are sent. However, a thread that's still loading - // also appears as a "draft" (no messages yet). + // A tracked draft (in `draft_threads`) is checked first via + // `active_draft_id`. Then we check for a thread with a session_id. + // If a thread is mid-load with no session_id yet, we fall back to + // `pending_remote_thread_activation` or keep the previous value. if let Some(active_ws) = &active_workspace { if let Some(panel) = active_ws.read(cx).panel::(cx) { - let active_thread_is_draft = panel.read(cx).active_thread_is_draft(cx); - let active_conversation_view = panel.read(cx).active_conversation_view(); - - if active_thread_is_draft || active_conversation_view.is_none() { - if active_conversation_view.is_none() - && let Some(session_id) = self.pending_remote_thread_activation.clone() - { - self.active_entry = Some(ActiveEntry::Thread { - session_id, - workspace: active_ws.clone(), - }); - } else { - let conversation_parent_id = - active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx)); - let preserving_thread = if let Some(ActiveEntry::Thread { - session_id, - .. - }) = &self.active_entry - { - self.active_entry_workspace() == Some(active_ws) - && conversation_parent_id - .as_ref() - .is_some_and(|id| id == session_id) - } else { - false - } || self - .pending_remote_thread_activation - .is_some(); - - if !preserving_thread { - // The active panel shows a draft. Read - // the draft ID from the AgentPanel (may be - // None for untracked drafts from Cmd-N). - let draft_id = active_ws - .read(cx) - .panel::(cx) - .and_then(|p| p.read(cx).active_draft_id()); - self.active_entry = Some(ActiveEntry::Draft { - id: draft_id, - workspace: active_ws.clone(), - }); - } - } - } else if let Some(session_id) = - active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx)) + let panel = panel.read(cx); + if let Some(draft_id) = panel.active_draft_id() { + self.active_entry = Some(ActiveEntry::Draft { + id: draft_id, + workspace: active_ws.clone(), + }); + } else if let Some(session_id) = panel + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) { if self.pending_remote_thread_activation.as_ref() == Some(&session_id) { self.pending_remote_thread_activation = None; @@ -900,9 +886,13 @@ impl Sidebar { session_id, workspace: active_ws.clone(), }); + } else if let Some(session_id) = self.pending_remote_thread_activation.clone() { + self.active_entry = Some(ActiveEntry::Thread { + session_id, + workspace: active_ws.clone(), + }); } - // else: conversation exists, not a draft, but no session_id - // yet — thread is mid-load. Keep previous value. + // else: conversation is mid-load (no session_id yet), keep previous active_entry } } @@ -1239,13 +1229,7 @@ impl Sidebar { for ws in group_workspaces { if let Some(panel) = ws.read(cx).panel::(cx) { let ids = panel.read(cx).draft_ids(); - if !ids.is_empty() { - dbg!( - "found drafts in panel", - group_key.display_name(&Default::default()), - ids.len() - ); - } + for draft_id in ids { group_draft_ids.push((draft_id, ws.clone())); } @@ -1672,9 +1656,6 @@ impl Sidebar { let key = key.clone(); let focus_handle = self.focus_handle.clone(); - // TODO DL: Hitting this button for the first time after compiling the app on a non-activated workspace - // is currently NOT creating a draft. It activates the workspace but it requires a second click to - // effectively create the draft. IconButton::new( SharedString::from(format!( "{id_prefix}project-header-new-thread-{ix}", @@ -1683,24 +1664,38 @@ impl Sidebar { ) .icon_size(IconSize::Small) .tooltip(move |_, cx| { - Tooltip::for_action_in("New Thread", &NewThread, &focus_handle, cx) + Tooltip::for_action_in( + "Start New Agent Thread", + &NewThread, + &focus_handle, + cx, + ) }) .on_click(cx.listener( move |this, _, window, cx| { this.collapsed_groups.remove(&key); this.selection = None; - if let Some(workspace) = - this.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx).workspace_for_paths( + // If the active workspace belongs to this + // group, use it (preserves linked worktree + // context). Otherwise resolve from the key. + let workspace = this.multi_workspace.upgrade().and_then(|mw| { + let mw = mw.read(cx); + let active = mw.workspace().clone(); + let active_key = active.read(cx).project_group_key(cx); + if active_key == key { + Some(active) + } else { + mw.workspace_for_paths( key.path_list(), key.host().as_ref(), cx, ) - }) - { + } + }); + if let Some(workspace) = workspace { this.create_new_thread(&workspace, window, cx); } else { - this.open_workspace_for_group(&key, window, cx); + this.open_workspace_and_create_draft(&key, window, cx); } }, )) @@ -1722,17 +1717,15 @@ impl Sidebar { cx, ) }) { - // Find an existing draft for this group - // and activate it, rather than creating - // a new one. - let draft_id = workspace - .read(cx) - .panel::(cx) - .and_then(|p| p.read(cx).draft_ids().first().copied()); - if let Some(draft_id) = draft_id { - this.activate_draft(draft_id, &workspace, window, cx); - } else { - this.create_new_thread(&workspace, window, cx); + // Just activate the workspace. The + // AgentPanel remembers what was last + // shown, so the user returns to whatever + // thread/draft they were looking at. + this.activate_workspace(&workspace, window, cx); + if AgentPanel::is_visible(&workspace, cx) { + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); } } else { this.open_workspace_for_group(&key, window, cx); @@ -1939,7 +1932,7 @@ impl Sidebar { let color = cx.theme().colors(); let background = color .title_bar_background - .blend(color.panel_background.opacity(0.25)); + .blend(color.panel_background.opacity(0.2)); let element = v_flex() .absolute() @@ -2190,6 +2183,8 @@ impl Sidebar { if let Some(workspace) = workspace { self.activate_draft(draft_id, &workspace, window, cx); } + } else if let Some(workspace) = workspace { + self.activate_workspace(&workspace, window, cx); } else { self.open_workspace_for_group(&key, window, cx); } @@ -2828,22 +2823,20 @@ impl Sidebar { .entries_for_path(folder_paths) .filter(|t| t.session_id != *session_id) .count(); + if remaining > 0 { return None; } let multi_workspace = self.multi_workspace.upgrade()?; - // Thread metadata doesn't carry host info yet, so we pass - // `None` here. This may match a local workspace with the same - // paths instead of the intended remote one. let workspace = multi_workspace .read(cx) .workspace_for_paths(folder_paths, None, cx)?; - // Don't remove the main worktree workspace — the project - // header always provides access to it. let group_key = workspace.read(cx).project_group_key(cx); - (group_key.path_list() != folder_paths).then_some(workspace) + let is_linked_worktree = group_key.path_list() != folder_paths; + + is_linked_worktree.then_some(workspace) }); if let Some(workspace_to_remove) = workspace_to_remove { @@ -2896,7 +2889,6 @@ impl Sidebar { }) .detach_and_log_err(cx); } else { - // Simple case: no workspace removal needed. let neighbor_metadata = neighbor.map(|(metadata, _)| metadata); let in_flight = self.start_archive_worktree_task(session_id, roots_to_archive, cx); self.archive_and_activate( @@ -2962,7 +2954,11 @@ impl Sidebar { .is_some_and(|id| id == *session_id); if panel_shows_archived { panel.update(cx, |panel, cx| { - panel.clear_active_thread(window, cx); + // Replace the archived thread with a + // tracked draft so the panel isn't left + // in Uninitialized state. + let id = panel.create_draft(window, cx); + panel.activate_draft(id, false, window, cx); }); } } @@ -2975,6 +2971,7 @@ impl Sidebar { // tell the panel to load it and activate that workspace. // `rebuild_contents` will reconcile `active_entry` once the thread // finishes loading. + if let Some(metadata) = neighbor { if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { mw.read(cx) @@ -2994,8 +2991,6 @@ impl Sidebar { .and_then(|folder_paths| { let mw = self.multi_workspace.upgrade()?; let mw = mw.read(cx); - // Find the group's main workspace (whose root paths match - // the project group key, not the thread's folder paths). let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?; let group_key = thread_workspace.read(cx).project_group_key(cx); mw.workspace_for_paths(group_key.path_list(), None, cx) @@ -3681,30 +3676,13 @@ impl Sidebar { // If there is a keyboard selection, walk backwards through // `project_header_indices` to find the header that owns the selected // row. Otherwise fall back to the active workspace. - let workspace = if let Some(selected_ix) = self.selection { - self.contents - .project_header_indices - .iter() - .rev() - .find(|&&header_ix| header_ix <= selected_ix) - .and_then(|&header_ix| match &self.contents.entries[header_ix] { - ListEntry::ProjectHeader { key, .. } => { - self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx).workspace_for_paths( - key.path_list(), - key.host().as_ref(), - cx, - ) - }) - } - _ => None, - }) - } else { - // Use the currently active workspace. - self.multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()) - }; + // Always use the currently active workspace so that drafts + // are created in the linked worktree the user is focused on, + // not the main worktree resolved from the project header. + let workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); let Some(workspace) = workspace else { return; @@ -3743,7 +3721,7 @@ impl Sidebar { if let Some(draft_id) = draft_id { self.active_entry = Some(ActiveEntry::Draft { - id: Some(draft_id), + id: draft_id, workspace: workspace.clone(), }); } @@ -3772,7 +3750,7 @@ impl Sidebar { }); self.active_entry = Some(ActiveEntry::Draft { - id: Some(draft_id), + id: draft_id, workspace: workspace.clone(), }); @@ -3803,14 +3781,15 @@ impl Sidebar { let mut switched = false; let group_key = workspace.read(cx).project_group_key(cx); - // Try the nearest draft in the same panel (prefer the - // next one in creation order, fall back to the previous). + // Try the next draft below in the sidebar (smaller ID + // since the list is newest-first). Fall back to the one + // above (larger ID) if the deleted draft was last. if let Some(panel) = workspace.read(cx).panel::(cx) { let ids = panel.read(cx).draft_ids(); let sibling = ids .iter() - .find(|id| id.0 > draft_id.0) - .or_else(|| ids.last()); + .find(|id| id.0 < draft_id.0) + .or_else(|| ids.first()); if let Some(&sibling_id) = sibling { self.activate_draft(sibling_id, workspace, window, cx); switched = true; @@ -3843,31 +3822,50 @@ impl Sidebar { self.update_entries(cx); } - /// Reads a draft's prompt text from its ConversationView in the AgentPanel. - fn read_draft_text( - &self, + fn clear_draft( + &mut self, draft_id: DraftId, workspace: &Entity, - cx: &App, - ) -> Option { - let panel = workspace.read(cx).panel::(cx)?; - let raw = panel.read(cx).draft_editor_text(draft_id, cx)?; - let cleaned = Self::clean_mention_links(&raw); - let mut text: String = cleaned.split_whitespace().collect::>().join(" "); + window: &mut Window, + cx: &mut Context, + ) { + workspace.update(cx, |ws, cx| { + if let Some(panel) = ws.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.clear_draft_editor(draft_id, window, cx); + }); + } + }); + self.update_entries(cx); + } + /// Cleans, collapses whitespace, and truncates raw editor text + /// for display as a draft label in the sidebar. + fn truncate_draft_label(raw: &str) -> Option { + let cleaned = Self::clean_mention_links(raw); + 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: DraftId, + workspace: &Entity, + cx: &App, + ) -> Option { + let panel = workspace.read(cx).panel::(cx)?; + let raw = panel.read(cx).draft_editor_text(draft_id, cx)?; + Self::truncate_draft_label(&raw) + } + fn active_project_group_key(&self, cx: &App) -> Option { let multi_workspace = self.multi_workspace.upgrade()?; let multi_workspace = multi_workspace.read(cx); @@ -4112,9 +4110,10 @@ impl Sidebar { ) -> AnyElement { let label: SharedString = draft_id .and_then(|id| workspace.and_then(|ws| self.read_draft_text(id, ws, cx))) - .unwrap_or_else(|| "Draft Thread".into()); + .unwrap_or_else(|| "New Agent Thread".into()); let id = SharedString::from(format!("draft-thread-btn-{}", ix)); + let worktrees = worktrees .iter() .map(|worktree| ThreadItemWorktreeInfo { @@ -4126,9 +4125,11 @@ impl Sidebar { .collect(); let is_hovered = self.hovered_thread_index == Some(ix); + let key = key.clone(); let workspace_for_click = workspace.cloned(); let workspace_for_remove = workspace.cloned(); + let workspace_for_clear = workspace.cloned(); ThreadItem::new(id, label) .icon(IconName::Pencil) @@ -4150,13 +4151,22 @@ impl Sidebar { if let Some(workspace) = &workspace_for_click { this.activate_draft(draft_id, workspace, window, cx); } + } else if let Some(workspace) = &workspace_for_click { + // Placeholder with an open workspace — just + // activate it. The panel remembers its last view. + this.activate_workspace(workspace, window, cx); + if AgentPanel::is_visible(workspace, cx) { + workspace.update(cx, |ws, cx| { + ws.focus_panel::(window, cx); + }); + } } else { - // Placeholder for a group with no workspace — open it. + // No workspace at all — just open one. The + // panel's load fallback will create a draft. this.open_workspace_for_group(&key, window, cx); } })) - .when(can_dismiss && draft_id.is_some(), |this| { - let draft_id = draft_id.unwrap(); + .when_some(draft_id.filter(|_| can_dismiss), |this, draft_id| { this.action_slot( div() .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { @@ -4180,6 +4190,30 @@ impl Sidebar { ), ) }) + .when_some(draft_id.filter(|_| !can_dismiss), |this, draft_id| { + this.action_slot( + div() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + IconButton::new( + SharedString::from(format!("clear-draft-{}", ix)), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Clear Draft")) + .on_click(cx.listener( + move |this, _, window, cx| { + if let Some(workspace) = &workspace_for_clear { + this.clear_draft(draft_id, workspace, window, cx); + } + }, + )), + ), + ) + }) .into_any_element() } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index f6f36ab619ff69552ddaaae381a0af51b0999e6a..af4d87bdfe09f6adad0f960d7683f578584a23be 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -340,11 +340,6 @@ fn visible_entries_as_strings( } else { "" }; - let is_active = sidebar - .active_entry - .as_ref() - .is_some_and(|active| active.matches_entry(entry)); - let active_indicator = if is_active { " (active)" } else { "" }; match entry { ListEntry::ProjectHeader { label, @@ -377,7 +372,7 @@ fn visible_entries_as_strings( "" }; let worktree = format_linked_worktree_chips(&thread.worktrees); - format!(" {title}{worktree}{live}{status_str}{notified}{active_indicator}{selected}") + format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } ListEntry::ViewMore { is_fully_expanded, .. @@ -1465,7 +1460,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", " Hello * (running)", ] ); @@ -1563,7 +1558,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp vec![ // "v [project-a]", - " Hello * (running) (active)", + " Hello * (running)", ] ); @@ -1577,7 +1572,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp vec![ // "v [project-a]", - " Hello * (!) (active)", + " Hello * (!)", ] ); } @@ -2269,7 +2264,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); @@ -2295,7 +2290,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Friendly Greeting with AI * (active)", + " Friendly Greeting with AI *", ] ); } @@ -2553,7 +2548,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex vec![ // "v [project-a]", - " Hello * (active)", + " Hello *", ] ); @@ -2588,8 +2583,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex vec![ "v [project-a, project-b]", // " Hello *", - "v [project-a]", - " [~ Draft]", ] ); @@ -3122,7 +3115,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) vec![ // "v [project-a, project-b]", - " [~ Draft] (active)", " Thread B", "v [project-a]", " Thread A", @@ -3203,7 +3195,6 @@ async fn test_worktree_collision_keeps_active_workspace(cx: &mut TestAppContext) vec![ // "v [project-a, project-b]", - " [~ Draft] (active)", " Thread A", " Worktree Thread {project-a:wt-feature}", " Thread B", @@ -3323,7 +3314,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext vec![ // "v [project]", - " [~ Draft {wt-feature}] (active)", " Worktree Thread {wt-feature}", " Main Thread", ] @@ -3382,7 +3372,6 @@ async fn test_worktree_add_syncs_linked_worktree_sibling(cx: &mut TestAppContext vec![ // "v [other-project, project]", - " [~ Draft {project:wt-feature}] (active)", " Worktree Thread {project:wt-feature}", " Main Thread", ] @@ -3417,7 +3406,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); @@ -3469,7 +3458,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) vec![ // "v [my-project]", - " Hello * (active)", + " Hello *", ] ); @@ -3495,6 +3484,72 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext) }); } +#[gpui::test] +async fn test_sending_message_from_draft_removes_draft(cx: &mut TestAppContext) { + // When the user sends a message from a draft thread, the draft + // should be removed from the sidebar and the active_entry should + // transition to a Thread pointing at the new session. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create a saved thread so the group isn't empty. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let existing_session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&existing_session_id, &project, cx).await; + cx.run_until_parked(); + + // Create a draft via Cmd-N. + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + cx.run_until_parked(); + + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *", " Hello *"], + "draft should be visible before sending", + ); + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft(sidebar, &workspace, "should be on draft before sending"); + }); + + // Now send a message from the draft. Set up the connection to + // respond so the thread gets content. + let draft_connection = StubAgentConnection::new(); + draft_connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("World".into()), + )]); + open_thread_with_connection(&panel, draft_connection, cx); + send_message(&panel, cx); + let new_session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&new_session_id, &project, cx).await; + cx.run_until_parked(); + + // The draft should be gone and the new thread should be active. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 0, + "draft should be removed after sending a message" + ); + + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread( + sidebar, + &new_session_id, + "active_entry should transition to the new thread after sending", + ); + }); +} + #[gpui::test] async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { // When the active workspace is an absorbed git worktree, cmd-n @@ -3579,7 +3634,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp vec![ // "v [project]", - " Hello {wt-feature-a} * (active)", + " Hello {wt-feature-a} *", ] ); @@ -5959,9 +6014,8 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { #[gpui::test] async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) { // When a thread is archived while the user is in a different workspace, - // the archiving code clears the thread from its panel (via - // `clear_active_thread`). Switching back to that workspace should show - // a draft, not the archived thread. + // the archiving code replaces the thread with a tracked draft in its + // panel. Switching back to that workspace should show the draft. agent_ui::test_support::init_test(cx); cx.update(|cx| { ThreadStore::init_global(cx); @@ -7215,6 +7269,366 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project ); } +#[gpui::test] +async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) { + // Rule 4: When the app starts and the AgentPanel fails to restore its + // last thread (no metadata), a draft should appear in the sidebar. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // In tests, AgentPanel::test_new doesn't call `load`, so no + // fallback draft is created. The empty group shows a placeholder. + // Simulate the startup fallback by creating a draft explicitly. + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *"] + ); + + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft(sidebar, &workspace, "should show active draft"); + }); +} + +#[gpui::test] +async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppContext) { + // Rule 5: When the app starts and the AgentPanel successfully loads + // a thread, no spurious draft should appear. + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create and send a message to make a real thread. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, &project, cx).await; + cx.run_until_parked(); + + // Should show the thread, NOT a spurious draft. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries, vec!["v [my-project]", " Hello *"]); + + // active_entry should be Thread, not Draft. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread(sidebar, &session_id, "should be on the thread, not a draft"); + }); +} + +#[gpui::test] +async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) { + // Rule 8: Deleting the last draft in a threadless group should + // leave a placeholder draft entry (not an empty group). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create two drafts explicitly (test_new doesn't call load). + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [~ Draft] *", " [~ Draft]"] + ); + + // Delete the active (first) draft. The second should become active. + let active_draft_id = sidebar.read_with(cx, |_sidebar, cx| { + workspace + .read(cx) + .panel::(cx) + .unwrap() + .read(cx) + .active_draft_id() + .unwrap() + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.remove_draft(active_draft_id, &workspace, window, cx); + }); + cx.run_until_parked(); + + // Should still have 1 draft (the remaining one), now active. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 1, "one draft should remain after deleting one"); + + // Delete the last remaining draft. + let last_draft_id = sidebar.read_with(cx, |_sidebar, cx| { + workspace + .read(cx) + .panel::(cx) + .unwrap() + .read(cx) + .active_draft_id() + .unwrap() + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.remove_draft(last_draft_id, &workspace, window, cx); + }); + cx.run_until_parked(); + + // The group has no threads and no tracked drafts, so a + // placeholder draft should appear. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 1, + "placeholder draft should appear after deleting all tracked drafts" + ); +} + +#[gpui::test] +async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) { + // Rule 9: Clicking a project header should restore whatever the + // user was last looking at in that group, not create new drafts + // or jump to the first entry. + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Create two threads in project-a. + let conn1 = StubAgentConnection::new(); + conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, conn1, cx); + send_message(&panel_a, cx); + let thread_a1 = active_session_id(&panel_a, cx); + save_test_thread_metadata(&thread_a1, &project_a, cx).await; + + let conn2 = StubAgentConnection::new(); + conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, conn2, cx); + send_message(&panel_a, cx); + let thread_a2 = active_session_id(&panel_a, cx); + save_test_thread_metadata(&thread_a2, &project_a, cx).await; + cx.run_until_parked(); + + // The user is now looking at thread_a2. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread(sidebar, &thread_a2, "should be on thread_a2"); + }); + + // Add project-b and switch to it. + let fs = cx.update(|_window, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = + project::Project::test(fs.clone() as Arc, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let _panel_b = add_agent_panel(&workspace_b, cx); + cx.run_until_parked(); + + // Now switch BACK to project-a by activating its workspace. + let workspace_a = multi_workspace.read_with(cx, |mw, cx| { + mw.workspaces() + .find(|ws| { + ws.read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .any(|wt| { + wt.read(cx) + .abs_path() + .to_string_lossy() + .contains("project-a") + }) + }) + .unwrap() + .clone() + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a.clone(), window, cx); + }); + cx.run_until_parked(); + + // The panel should still show thread_a2 (the last thing the user + // was viewing in project-a), not a draft or thread_a1. + sidebar.read_with(cx, |sidebar, _| { + assert_active_thread( + sidebar, + &thread_a2, + "switching back to project-a should restore thread_a2", + ); + }); + + // No spurious draft entries should have been created in + // project-a's group (project-b may have a placeholder). + let entries = visible_entries_as_strings(&sidebar, cx); + // Find project-a's section and check it has no drafts. + let project_a_start = entries + .iter() + .position(|e| e.contains("project-a")) + .unwrap(); + let project_a_end = entries[project_a_start + 1..] + .iter() + .position(|e| e.starts_with("v ")) + .map(|i| i + project_a_start + 1) + .unwrap_or(entries.len()); + let project_a_drafts = entries[project_a_start..project_a_end] + .iter() + .filter(|e| e.contains("Draft")) + .count(); + assert_eq!( + project_a_drafts, 0, + "switching back to project-a should not create drafts in its group" + ); +} + +#[gpui::test] +async fn test_plus_button_always_creates_new_draft(cx: &mut TestAppContext) { + // Rule 3: Clicking the + button on a group should always create + // a new draft, even starting from a placeholder (no tracked drafts). + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + // Start: panel has no tracked drafts, sidebar shows a placeholder. + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 1, "should start with 1 placeholder"); + + // Simulate what the + button handler does: create exactly one + // new draft per click. + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let simulate_plus_button = + |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context| { + sidebar.create_new_thread(&workspace, window, cx); + }; + + // First + click: placeholder -> 1 tracked draft. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!( + draft_count, 1, + "first + click on placeholder should produce 1 tracked draft" + ); + + // Second + click: 1 -> 2 drafts. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 2, "second + click should add 1 more draft"); + + // Third + click: 2 -> 3 drafts. + sidebar.update_in(cx, |sidebar, window, cx| { + simulate_plus_button(sidebar, window, cx); + }); + cx.run_until_parked(); + + let entries = visible_entries_as_strings(&sidebar, cx); + let draft_count = entries.iter().filter(|e| e.contains("Draft")).count(); + assert_eq!(draft_count, 3, "third + click should add 1 more draft"); + + // The most recently created draft should be active (first in list). + assert_eq!(entries[1], " [~ Draft] *"); +} + +#[gpui::test] +async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) { + // When a workspace has a draft (from the panel's load fallback) + // and the user activates it (e.g. by clicking the placeholder or + // the project header), no extra drafts should be created. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = + project::Project::test(fs.clone() as Arc, ["/project-a".as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let _panel_a = add_agent_panel(&workspace_a, cx); + cx.run_until_parked(); + + // Add project-b with its own workspace and agent panel. + let project_b = + project::Project::test(fs.clone() as Arc, ["/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(); + + // Count project-b's drafts. + let count_b_drafts = |cx: &mut gpui::VisualTestContext| { + let entries = visible_entries_as_strings(&sidebar, cx); + entries + .iter() + .skip_while(|e| !e.contains("project-b")) + .take_while(|e| !e.starts_with("v ") || e.contains("project-b")) + .filter(|e| e.contains("Draft")) + .count() + }; + let drafts_before = count_b_drafts(cx); + + // Switch away from project-b, then back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a.clone(), window, cx); + }); + cx.run_until_parked(); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_b.clone(), window, cx); + }); + cx.run_until_parked(); + + let drafts_after = count_b_drafts(cx); + assert_eq!( + drafts_before, drafts_after, + "activating workspace should not create extra drafts" + ); + + // The draft should be highlighted as active after switching back. + sidebar.read_with(cx, |sidebar, _| { + assert_active_draft( + sidebar, + &workspace_b, + "draft should be active after switching back to its workspace", + ); + }); +} + mod property_test { use super::*; use gpui::proptest::prelude::*; @@ -7886,10 +8300,10 @@ mod property_test { // 3. The entry must match the agent panel's current state. let panel = active_workspace.read(cx).panel::(cx).unwrap(); - if panel.read(cx).active_thread_is_draft(cx) { + if panel.read(cx).active_draft_id().is_some() { anyhow::ensure!( matches!(entry, ActiveEntry::Draft { .. }), - "panel shows a draft but active_entry is {:?}", + "panel shows a tracked draft but active_entry is {:?}", entry, ); } else if let Some(session_id) = panel