@@ -122,9 +122,7 @@ enum ActiveEntry {
workspace: Entity<Workspace>,
},
Draft {
- /// `None` for untracked drafts (e.g., from Cmd-N keyboard shortcut
- /// that goes directly through the AgentPanel).
- id: Option<DraftId>,
+ id: DraftId,
workspace: Entity<Workspace>,
},
}
@@ -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<Self>,
+ ) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let path_list = project_group_key.path_list().clone();
+ let host = project_group_key.host();
+ let provisional_key = Some(project_group_key.clone());
+ let active_workspace = multi_workspace.read(cx).workspace().clone();
+
+ let task = multi_workspace.update(cx, |this, cx| {
+ this.find_or_create_workspace(
+ path_list,
+ host,
+ provisional_key,
+ |options, window, cx| connect_remote(active_workspace, options, window, cx),
+ window,
+ cx,
+ )
+ });
+
+ cx.spawn_in(window, async move |this, cx| {
+ let workspace = task.await?;
+ this.update_in(cx, |this, window, cx| {
+ this.create_new_thread(&workspace, window, cx);
+ })?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
/// Rebuilds the sidebar contents from current workspace and thread state.
///
/// Iterates [`MultiWorkspace::project_group_keys`] to determine project
@@ -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::<AgentPanel>(cx) {
- let active_thread_is_draft = panel.read(cx).active_thread_is_draft(cx);
- let active_conversation_view = panel.read(cx).active_conversation_view();
-
- if active_thread_is_draft || active_conversation_view.is_none() {
- if active_conversation_view.is_none()
- && let Some(session_id) = self.pending_remote_thread_activation.clone()
- {
- self.active_entry = Some(ActiveEntry::Thread {
- session_id,
- workspace: active_ws.clone(),
- });
- } else {
- let conversation_parent_id =
- active_conversation_view.and_then(|cv| cv.read(cx).parent_id(cx));
- let preserving_thread = if let Some(ActiveEntry::Thread {
- session_id,
- ..
- }) = &self.active_entry
- {
- self.active_entry_workspace() == Some(active_ws)
- && conversation_parent_id
- .as_ref()
- .is_some_and(|id| id == session_id)
- } else {
- false
- } || 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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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::<AgentPanel>(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<Workspace>,
- cx: &App,
- ) -> Option<SharedString> {
- let panel = workspace.read(cx).panel::<AgentPanel>(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::<Vec<_>>().join(" ");
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.clear_draft_editor(draft_id, window, cx);
+ });
+ }
+ });
+ self.update_entries(cx);
+ }
+ /// Cleans, collapses whitespace, and truncates raw editor text
+ /// for display as a draft label in the sidebar.
+ fn truncate_draft_label(raw: &str) -> Option<SharedString> {
+ let cleaned = Self::clean_mention_links(raw);
+ let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
if text.is_empty() {
return None;
}
-
const MAX_CHARS: usize = 250;
-
if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
text.truncate(truncate_at);
}
-
Some(text.into())
}
+ /// Reads a draft's prompt text from its ConversationView in the AgentPanel.
+ fn read_draft_text(
+ &self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ cx: &App,
+ ) -> Option<SharedString> {
+ let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let raw = panel.read(cx).draft_editor_text(draft_id, cx)?;
+ Self::truncate_draft_label(&raw)
+ }
+
fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
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::<AgentPanel>(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()
}
@@ -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::<AgentPanel>(cx)
+ .unwrap()
+ .read(cx)
+ .active_draft_id()
+ .unwrap()
+ });
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.remove_draft(active_draft_id, &workspace, window, cx);
+ });
+ cx.run_until_parked();
+
+ // Should still have 1 draft (the remaining one), now active.
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(draft_count, 1, "one draft should remain after deleting one");
+
+ // Delete the last remaining draft.
+ let last_draft_id = sidebar.read_with(cx, |_sidebar, cx| {
+ workspace
+ .read(cx)
+ .panel::<AgentPanel>(cx)
+ .unwrap()
+ .read(cx)
+ .active_draft_id()
+ .unwrap()
+ });
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.remove_draft(last_draft_id, &workspace, window, cx);
+ });
+ cx.run_until_parked();
+
+ // The group has no threads and no tracked drafts, so a
+ // placeholder draft should appear.
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(
+ draft_count, 1,
+ "placeholder draft should appear after deleting all tracked drafts"
+ );
+}
+
+#[gpui::test]
+async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
+ // Rule 9: Clicking a project header should restore whatever the
+ // user was last looking at in that group, not create new drafts
+ // or jump to the first entry.
+ let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+ let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+ // Create two threads in project-a.
+ let conn1 = StubAgentConnection::new();
+ conn1.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done".into()),
+ )]);
+ open_thread_with_connection(&panel_a, conn1, cx);
+ send_message(&panel_a, cx);
+ let thread_a1 = active_session_id(&panel_a, cx);
+ save_test_thread_metadata(&thread_a1, &project_a, cx).await;
+
+ let conn2 = StubAgentConnection::new();
+ conn2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ acp::ContentChunk::new("Done".into()),
+ )]);
+ open_thread_with_connection(&panel_a, conn2, cx);
+ send_message(&panel_a, cx);
+ let thread_a2 = active_session_id(&panel_a, cx);
+ save_test_thread_metadata(&thread_a2, &project_a, cx).await;
+ cx.run_until_parked();
+
+ // The user is now looking at thread_a2.
+ sidebar.read_with(cx, |sidebar, _| {
+ assert_active_thread(sidebar, &thread_a2, "should be on thread_a2");
+ });
+
+ // Add project-b and switch to it.
+ let fs = cx.update(|_window, cx| <dyn fs::Fs>::global(cx));
+ fs.as_fake()
+ .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+ .await;
+ let project_b =
+ project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
+ let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b.clone(), window, cx)
+ });
+ let _panel_b = add_agent_panel(&workspace_b, cx);
+ cx.run_until_parked();
+
+ // Now switch BACK to project-a by activating its workspace.
+ let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
+ mw.workspaces()
+ .find(|ws| {
+ ws.read(cx)
+ .project()
+ .read(cx)
+ .visible_worktrees(cx)
+ .any(|wt| {
+ wt.read(cx)
+ .abs_path()
+ .to_string_lossy()
+ .contains("project-a")
+ })
+ })
+ .unwrap()
+ .clone()
+ });
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate(workspace_a.clone(), window, cx);
+ });
+ cx.run_until_parked();
+
+ // The panel should still show thread_a2 (the last thing the user
+ // was viewing in project-a), not a draft or thread_a1.
+ sidebar.read_with(cx, |sidebar, _| {
+ assert_active_thread(
+ sidebar,
+ &thread_a2,
+ "switching back to project-a should restore thread_a2",
+ );
+ });
+
+ // No spurious draft entries should have been created in
+ // project-a's group (project-b may have a placeholder).
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ // Find project-a's section and check it has no drafts.
+ let project_a_start = entries
+ .iter()
+ .position(|e| e.contains("project-a"))
+ .unwrap();
+ let project_a_end = entries[project_a_start + 1..]
+ .iter()
+ .position(|e| e.starts_with("v "))
+ .map(|i| i + project_a_start + 1)
+ .unwrap_or(entries.len());
+ let project_a_drafts = entries[project_a_start..project_a_end]
+ .iter()
+ .filter(|e| e.contains("Draft"))
+ .count();
+ assert_eq!(
+ project_a_drafts, 0,
+ "switching back to project-a should not create drafts in its group"
+ );
+}
+
+#[gpui::test]
+async fn test_plus_button_always_creates_new_draft(cx: &mut TestAppContext) {
+ // Rule 3: Clicking the + button on a group should always create
+ // a new draft, even starting from a placeholder (no tracked drafts).
+ let project = init_test_project_with_agent_panel("/my-project", cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+ // Start: panel has no tracked drafts, sidebar shows a placeholder.
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(draft_count, 1, "should start with 1 placeholder");
+
+ // Simulate what the + button handler does: create exactly one
+ // new draft per click.
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+ let simulate_plus_button =
+ |sidebar: &mut Sidebar, window: &mut Window, cx: &mut Context<Sidebar>| {
+ sidebar.create_new_thread(&workspace, window, cx);
+ };
+
+ // First + click: placeholder -> 1 tracked draft.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ simulate_plus_button(sidebar, window, cx);
+ });
+ cx.run_until_parked();
+
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(
+ draft_count, 1,
+ "first + click on placeholder should produce 1 tracked draft"
+ );
+
+ // Second + click: 1 -> 2 drafts.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ simulate_plus_button(sidebar, window, cx);
+ });
+ cx.run_until_parked();
+
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(draft_count, 2, "second + click should add 1 more draft");
+
+ // Third + click: 2 -> 3 drafts.
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ simulate_plus_button(sidebar, window, cx);
+ });
+ cx.run_until_parked();
+
+ let entries = visible_entries_as_strings(&sidebar, cx);
+ let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
+ assert_eq!(draft_count, 3, "third + click should add 1 more draft");
+
+ // The most recently created draft should be active (first in list).
+ assert_eq!(entries[1], " [~ Draft] *");
+}
+
+#[gpui::test]
+async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut TestAppContext) {
+ // When a workspace has a draft (from the panel's load fallback)
+ // and the user activates it (e.g. by clicking the placeholder or
+ // the project header), no extra drafts should be created.
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/project-a", serde_json::json!({ ".git": {}, "src": {} }))
+ .await;
+ fs.insert_tree("/project-b", serde_json::json!({ ".git": {}, "src": {} }))
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+ let project_a =
+ project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-a".as_ref()], cx).await;
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+ let sidebar = setup_sidebar(&multi_workspace, cx);
+ let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+ let _panel_a = add_agent_panel(&workspace_a, cx);
+ cx.run_until_parked();
+
+ // Add project-b with its own workspace and agent panel.
+ let project_b =
+ project::Project::test(fs.clone() as Arc<dyn Fs>, ["/project-b".as_ref()], cx).await;
+ let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b.clone(), window, cx)
+ });
+ let _panel_b = add_agent_panel(&workspace_b, cx);
+ cx.run_until_parked();
+
+ // 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::<AgentPanel>(cx).unwrap();
- if panel.read(cx).active_thread_is_draft(cx) {
+ if panel.read(cx).active_draft_id().is_some() {
anyhow::ensure!(
matches!(entry, ActiveEntry::Draft { .. }),
- "panel shows a draft but active_entry is {:?}",
+ "panel shows a tracked draft but active_entry is {:?}",
entry,
);
} else if let Some(session_id) = panel