@@ -204,21 +204,12 @@ pub fn init(cx: &mut App) {
panel.update(cx, |panel, cx| panel.open_configuration(window, cx));
}
})
- .register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
+ .register_action(|workspace, _action: &NewExternalAgentThread, window, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(window, cx);
panel.update(cx, |panel, cx| {
- let initial_content = panel.take_active_draft_initial_content(cx);
- panel.external_thread(
- action.agent.clone(),
- None,
- None,
- None,
- initial_content,
- true,
- window,
- cx,
- )
+ let id = panel.create_draft(window, cx);
+ panel.activate_draft(id, true, window, cx);
});
}
})
@@ -602,6 +593,19 @@ fn build_conflicted_files_resolution_prompt(
content
}
+/// Unique identifier for a sidebar draft thread. Not persisted across restarts.
+/// IDs are globally unique across all AgentPanel instances.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct DraftId(pub usize);
+
+static NEXT_DRAFT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+
+impl DraftId {
+ fn next() -> Self {
+ Self(NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
+ }
+}
+
enum ActiveView {
Uninitialized,
AgentThread {
@@ -803,6 +807,7 @@ pub struct AgentPanel {
active_view: ActiveView,
previous_view: Option<ActiveView>,
background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
+ draft_threads: HashMap<DraftId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
@@ -1181,6 +1186,7 @@ impl AgentPanel {
context_server_registry,
previous_view: None,
background_threads: HashMap::default(),
+ draft_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
thread_branch_menu_handle: PopoverMenuHandle::default(),
@@ -1306,9 +1312,126 @@ impl AgentPanel {
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
+ let id = self.create_draft(window, cx);
+ self.activate_draft(id, true, window, cx);
+ }
+
+ pub fn new_empty_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.reset_start_thread_in_to_default(cx);
- let initial_content = self.take_active_draft_initial_content(cx);
- self.external_thread(None, None, None, None, initial_content, true, window, cx);
+ 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<Self>) -> DraftId {
+ let id = DraftId::next();
+
+ let workspace = self.workspace.clone();
+ let project = self.project.clone();
+ let fs = self.fs.clone();
+ let thread_store = self.thread_store.clone();
+ let agent = if self.project.read(cx).is_via_collab() {
+ Agent::NativeAgent
+ } else {
+ self.selected_agent.clone()
+ };
+ let server = agent.server(fs, thread_store);
+
+ let thread_store = server
+ .clone()
+ .downcast::<agent::NativeAgentServer>()
+ .is_some()
+ .then(|| self.thread_store.clone());
+
+ let connection_store = self.connection_store.clone();
+
+ let conversation_view = cx.new(|cx| {
+ crate::ConversationView::new(
+ server,
+ connection_store,
+ agent,
+ None,
+ None,
+ None,
+ None,
+ workspace,
+ project,
+ thread_store,
+ self.prompt_store.clone(),
+ window,
+ cx,
+ )
+ });
+
+ cx.observe(&conversation_view, |this, server_view, cx| {
+ let is_active = this
+ .active_conversation_view()
+ .is_some_and(|active| active.entity_id() == server_view.entity_id());
+ if is_active {
+ cx.emit(AgentPanelEvent::ActiveViewChanged);
+ this.serialize(cx);
+ } else {
+ cx.emit(AgentPanelEvent::BackgroundThreadChanged);
+ }
+ cx.notify();
+ })
+ .detach();
+
+ self.draft_threads.insert(id, conversation_view);
+ id
+ }
+
+ pub fn activate_draft(
+ &mut self,
+ id: DraftId,
+ focus: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(conversation_view) = self.draft_threads.get(&id).cloned() else {
+ return;
+ };
+ self.set_active_view(
+ ActiveView::AgentThread { conversation_view },
+ focus,
+ window,
+ cx,
+ );
+ }
+
+ /// Removes a draft thread. If it's currently active, does nothing to
+ /// the active view — the caller should activate something else first.
+ pub fn remove_draft(&mut self, id: DraftId) {
+ self.draft_threads.remove(&id);
+ }
+
+ /// Returns the DraftId of the currently active draft, if the active
+ /// view is a draft thread tracked in `draft_threads`.
+ pub fn active_draft_id(&self) -> Option<DraftId> {
+ let active_cv = self.active_conversation_view()?;
+ self.draft_threads
+ .iter()
+ .find_map(|(id, cv)| (cv.entity_id() == active_cv.entity_id()).then_some(*id))
+ }
+
+ /// Returns all draft IDs, sorted newest-first.
+ pub fn draft_ids(&self) -> Vec<DraftId> {
+ let mut ids: Vec<DraftId> = self.draft_threads.keys().copied().collect();
+ ids.sort_by_key(|id| std::cmp::Reverse(id.0));
+ ids
+ }
+
+ /// Returns the text from a draft's message editor, or `None` if the
+ /// draft doesn't exist or has no text.
+ pub fn draft_editor_text(&self, id: DraftId, cx: &App) -> Option<String> {
+ let cv = self.draft_threads.get(&id)?;
+ let tv = cv.read(cx).active_thread()?;
+ let text = tv.read(cx).message_editor.read(cx).text(cx);
+ if text.trim().is_empty() {
+ None
+ } else {
+ Some(text)
+ }
}
fn take_active_draft_initial_content(
@@ -1982,6 +2105,16 @@ impl AgentPanel {
return;
};
+ // If this ConversationView is a tracked draft, it's already
+ // stored in `draft_threads` — don't drop it.
+ let is_tracked_draft = self
+ .draft_threads
+ .values()
+ .any(|cv| cv.entity_id() == conversation_view.entity_id());
+ if is_tracked_draft {
+ return;
+ }
+
let Some(thread_view) = conversation_view.read(cx).root_thread(cx) else {
return;
};
@@ -9,9 +9,9 @@ use agent_ui::thread_worktree_archive;
use agent_ui::threads_archive_view::{
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
};
-use agent_ui::{AcpThreadImportOnboarding, ThreadImportModal};
use agent_ui::{
- Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread,
+ AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, DraftId,
+ NewThread, RemoveSelectedThread, ThreadImportModal,
};
use chrono::{DateTime, Utc};
use editor::Editor;
@@ -121,14 +121,19 @@ enum ActiveEntry {
session_id: acp::SessionId,
workspace: Entity<Workspace>,
},
- Draft(Entity<Workspace>),
+ Draft {
+ /// `None` for untracked drafts (e.g., from Cmd-N keyboard shortcut
+ /// that goes directly through the AgentPanel).
+ id: Option<DraftId>,
+ workspace: Entity<Workspace>,
+ },
}
impl ActiveEntry {
fn workspace(&self) -> &Entity<Workspace> {
match self {
ActiveEntry::Thread { workspace, .. } => workspace,
- ActiveEntry::Draft(workspace) => workspace,
+ ActiveEntry::Draft { workspace, .. } => workspace,
}
}
@@ -136,17 +141,33 @@ impl ActiveEntry {
matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id)
}
+ fn is_active_draft(&self, draft_id: DraftId) -> bool {
+ matches!(self, ActiveEntry::Draft { id: Some(id), .. } if *id == draft_id)
+ }
+
fn matches_entry(&self, entry: &ListEntry) -> bool {
match (self, entry) {
(ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
thread.metadata.session_id == *session_id
}
(
- ActiveEntry::Draft(_),
+ ActiveEntry::Draft {
+ id,
+ workspace: active_ws,
+ },
ListEntry::DraftThread {
- workspace: None, ..
+ draft_id,
+ workspace: entry_ws,
+ ..
},
- ) => true,
+ ) => 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,
+ },
_ => false,
}
}
@@ -245,9 +266,10 @@ enum ListEntry {
key: ProjectGroupKey,
is_fully_expanded: bool,
},
- /// The user's active draft thread. Shows a prefix of the currently-typed
- /// prompt, or "Untitled Thread" if the prompt is empty.
DraftThread {
+ /// `None` for placeholder entries in empty groups with no open
+ /// workspace. `Some` for drafts backed by an AgentPanel.
+ draft_id: Option<DraftId>,
key: project::ProjectGroupKey,
workspace: Option<Entity<Workspace>>,
worktrees: Vec<WorktreeInfo>,
@@ -273,15 +295,7 @@ impl ListEntry {
ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
ThreadEntryWorkspace::Closed { .. } => Vec::new(),
},
- ListEntry::DraftThread { workspace, .. } => {
- if let Some(ws) = workspace {
- vec![ws.clone()]
- } else {
- // workspace: None means this is the active draft,
- // which always lives on the current workspace.
- vec![multi_workspace.workspace().clone()]
- }
- }
+ ListEntry::DraftThread { workspace, .. } => workspace.iter().cloned().collect(),
ListEntry::ProjectHeader { key, .. } => multi_workspace
.workspaces_for_project_group(key, cx)
.cloned()
@@ -675,21 +689,10 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
- |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+ |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged => {
- let is_new_draft = agent_panel
- .read(cx)
- .active_conversation_view()
- .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
- if is_new_draft {
- if let Some(active_workspace) = this
- .multi_workspace
- .upgrade()
- .map(|mw| mw.read(cx).workspace().clone())
- {
- this.active_entry = Some(ActiveEntry::Draft(active_workspace));
- }
- }
+ // active_entry is fully derived during
+ // rebuild_contents — just trigger a rebuild.
this.observe_draft_editor(cx);
this.update_entries(cx);
}
@@ -749,26 +752,6 @@ impl Sidebar {
});
}
- fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
- let mw = self.multi_workspace.upgrade()?;
- let workspace = mw.read(cx).workspace();
- let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
- let conversation_view = panel.read(cx).active_conversation_view()?;
- let thread_view = conversation_view.read(cx).active_thread()?;
- let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
- let cleaned = Self::clean_mention_links(&raw);
- let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
- if text.is_empty() {
- None
- } else {
- const MAX_CHARS: usize = 250;
- if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
- text.truncate(truncate_at);
- }
- Some(text.into())
- }
- }
-
fn clean_mention_links(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut remaining = input;
@@ -889,9 +872,22 @@ impl Sidebar {
.is_some_and(|id| id == session_id)
} else {
false
- };
+ } || self
+ .pending_remote_thread_activation
+ .is_some();
+
if !preserving_thread {
- self.active_entry = Some(ActiveEntry::Draft(active_ws.clone()));
+ // 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) =
@@ -1221,9 +1217,6 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
- let is_draft_for_group = is_active
- && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
-
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
key: group_key.clone(),
@@ -1239,66 +1232,49 @@ impl Sidebar {
continue;
}
- // Emit a DraftThread entry when the active draft belongs to this group.
- if is_draft_for_group {
- if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
- let ws_worktree_paths = ThreadWorktreePaths::from_project(
- draft_ws.read(cx).project().read(cx),
- cx,
- );
- let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
- entries.push(ListEntry::DraftThread {
- key: group_key.clone(),
- workspace: None,
- worktrees,
- });
- }
- }
-
- // Emit a DraftThread for each open linked worktree workspace
- // that has no threads. Skip the specific workspace that is
- // showing the active draft (it already has a DraftThread entry
- // from the block above).
+ // Emit DraftThread entries by reading draft IDs from
+ // each workspace's AgentPanel in this group.
{
- let draft_ws_id = if is_draft_for_group {
- self.active_entry.as_ref().and_then(|e| match e {
- ActiveEntry::Draft(ws) => Some(ws.entity_id()),
- _ => None,
- })
- } else {
- None
- };
- let thread_store = ThreadMetadataStore::global(cx);
+ let mut group_draft_ids: Vec<(DraftId, Entity<Workspace>)> = Vec::new();
for ws in group_workspaces {
- if Some(ws.entity_id()) == draft_ws_id {
- continue;
- }
- let ws_worktree_paths =
- ThreadWorktreePaths::from_project(ws.read(cx).project().read(cx), cx);
- let has_linked_worktrees =
- worktree_info_from_thread_paths(&ws_worktree_paths)
- .iter()
- .any(|wt| wt.kind == ui::WorktreeKind::Linked);
- if !has_linked_worktrees {
- continue;
- }
- let ws_path_list = workspace_path_list(ws, cx);
- let store = thread_store.read(cx);
- let has_threads = store.entries_for_path(&ws_path_list).next().is_some()
- || store
- .entries_for_main_worktree_path(&ws_path_list)
- .next()
- .is_some();
- if has_threads {
- continue;
+ if let Some(panel) = ws.read(cx).panel::<AgentPanel>(cx) {
+ let ids = panel.read(cx).draft_ids();
+ 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()));
+ }
}
- let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
+ }
+ // For empty groups with no drafts, emit a
+ // placeholder DraftThread.
+ if !has_threads && group_draft_ids.is_empty() {
entries.push(ListEntry::DraftThread {
+ draft_id: None,
key: group_key.clone(),
- workspace: Some(ws.clone()),
- worktrees,
+ workspace: group_workspaces.first().cloned(),
+ worktrees: Vec::new(),
});
+ } else {
+ for (draft_id, ws) in &group_draft_ids {
+ let ws_worktree_paths = ThreadWorktreePaths::from_project(
+ ws.read(cx).project().read(cx),
+ cx,
+ );
+ let worktrees = worktree_info_from_thread_paths(&ws_worktree_paths);
+ entries.push(ListEntry::DraftThread {
+ draft_id: Some(*draft_id),
+ key: group_key.clone(),
+ workspace: Some(ws.clone()),
+ worktrees,
+ });
+ }
}
}
@@ -1457,15 +1433,35 @@ impl Sidebar {
is_fully_expanded,
} => self.render_view_more(ix, key, *is_fully_expanded, is_selected, cx),
ListEntry::DraftThread {
+ draft_id,
key,
workspace,
worktrees,
} => {
- if workspace.is_some() {
- self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx)
- } else {
- self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
- }
+ // TODO DL: Maybe these can derived somewhere else? Maybe in update or rebuild?
+ let group_has_threads = self
+ .contents
+ .entries
+ .iter()
+ .any(|e| matches!(e, ListEntry::ProjectHeader { key: hk, has_threads: true, .. } if hk == key));
+ // Count drafts in the AgentPanel for this group's workspaces.
+ let sibling_draft_count = workspace
+ .as_ref()
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .map(|p| p.read(cx).draft_ids().len())
+ .unwrap_or(0);
+ let can_dismiss = group_has_threads || sibling_draft_count > 1;
+ self.render_draft_thread(
+ ix,
+ *draft_id,
+ key,
+ workspace.as_ref(),
+ is_active,
+ worktrees,
+ is_selected,
+ can_dismiss,
+ cx,
+ )
}
};
@@ -1533,12 +1529,6 @@ impl Sidebar {
(IconName::ChevronDown, "Collapse Project")
};
- let has_new_thread_entry = self
- .contents
- .entries
- .get(ix + 1)
- .is_some_and(|entry| matches!(entry, ListEntry::DraftThread { .. }));
-
let key_for_toggle = key.clone();
let key_for_collapse = key.clone();
let view_more_expanded = self.expanded_groups.contains_key(key);
@@ -1560,20 +1550,15 @@ impl Sidebar {
let base_bg = color.background.blend(sidebar_base_bg);
- let hover_color = color
+ let hover_base = color
.element_active
.blend(color.element_background.opacity(0.2));
- let hover_bg = base_bg.blend(hover_color);
-
- let effective_hover = if !has_threads && is_active {
- base_bg
- } else {
- hover_bg
- };
+ let hover_solid = base_bg.blend(hover_base);
+ let real_hover_color = if is_active { base_bg } else { hover_solid };
let group_name_for_gradient = group_name.clone();
let gradient_overlay = move || {
- GradientFade::new(base_bg, effective_hover, effective_hover)
+ GradientFade::new(base_bg, real_hover_color, real_hover_color)
.width(px(64.0))
.right(px(-2.0))
.gradient_stop(0.75)
@@ -1686,6 +1671,10 @@ impl Sidebar {
.child({
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}",
@@ -1723,7 +1712,7 @@ impl Sidebar {
} else {
let key = key.clone();
this.cursor_pointer()
- .when(!is_active, |this| this.hover(|s| s.bg(hover_color)))
+ .when(!is_active, |this| this.hover(|s| s.bg(hover_solid)))
.tooltip(Tooltip::text("Open Workspace"))
.on_click(cx.listener(move |this, _, window, cx| {
if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| {
@@ -1733,16 +1722,17 @@ impl Sidebar {
cx,
)
}) {
- this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
- if let Some(multi_workspace) = this.multi_workspace.upgrade() {
- multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(workspace.clone(), window, cx);
- });
- }
- if AgentPanel::is_visible(&workspace, cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.focus_panel::<AgentPanel>(window, 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);
}
} else {
this.open_workspace_for_group(&key, window, cx);
@@ -2183,16 +2173,19 @@ impl Sidebar {
self.expand_thread_group(&key, cx);
}
}
- ListEntry::DraftThread { key, workspace, .. } => {
+ ListEntry::DraftThread {
+ draft_id,
+ key,
+ workspace,
+ ..
+ } => {
+ let draft_id = *draft_id;
let key = key.clone();
let workspace = workspace.clone();
- if let Some(workspace) = workspace.or_else(|| {
- self.multi_workspace.upgrade().and_then(|mw| {
- mw.read(cx)
- .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
- })
- }) {
- self.create_new_thread(&workspace, window, cx);
+ if let Some(draft_id) = draft_id {
+ if let Some(workspace) = workspace {
+ self.activate_draft(draft_id, &workspace, window, cx);
+ }
} else {
self.open_workspace_for_group(&key, window, cx);
}
@@ -2370,10 +2363,10 @@ impl Sidebar {
};
let pending_session_id = metadata.session_id.clone();
- let is_remote = project_group_key.host().is_some();
- if is_remote {
- self.pending_remote_thread_activation = Some(pending_session_id.clone());
- }
+ // Mark the pending thread activation so rebuild_contents
+ // preserves the Thread active_entry during loading (prevents
+ // spurious draft flash).
+ self.pending_remote_thread_activation = Some(pending_session_id.clone());
let host = project_group_key.host();
let provisional_key = Some(project_group_key.clone());
@@ -2397,7 +2390,7 @@ impl Sidebar {
// failures or cancellations do not leave a stale connection modal behind.
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
- if result.is_err() || is_remote {
+ if result.is_err() {
this.update(cx, |this, _cx| {
if this.pending_remote_thread_activation.as_ref() == Some(&pending_session_id) {
this.pending_remote_thread_activation = None;
@@ -3007,11 +3000,7 @@ impl Sidebar {
if let Some(workspace) = fallback_workspace {
self.activate_workspace(&workspace, window, cx);
- if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.new_thread(&NewThread, window, cx);
- });
- }
+ self.create_new_thread(&workspace, window, cx);
}
}
@@ -3138,35 +3127,18 @@ impl Sidebar {
self.archive_thread(&session_id, window, cx);
}
Some(ListEntry::DraftThread {
+ draft_id: Some(draft_id),
workspace: Some(workspace),
..
}) => {
- self.remove_worktree_workspace(workspace.clone(), window, cx);
+ let draft_id = *draft_id;
+ let workspace = workspace.clone();
+ self.remove_draft(draft_id, &workspace, window, cx);
}
_ => {}
}
}
- fn remove_worktree_workspace(
- &mut self,
- workspace: Entity<Workspace>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(multi_workspace) = self.multi_workspace.upgrade() {
- multi_workspace
- .update(cx, |mw, cx| {
- mw.remove(
- [workspace],
- |this, _window, _cx| gpui::Task::ready(Ok(this.workspace().clone())),
- window,
- cx,
- )
- })
- .detach_and_log_err(cx);
- }
- }
-
fn record_thread_access(&mut self, session_id: &acp::SessionId) {
self.thread_last_accessed
.insert(session_id.clone(), Utc::now());
@@ -3747,20 +3719,149 @@ impl Sidebar {
return;
};
- self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
-
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), window, cx);
});
- workspace.update(cx, |workspace, cx| {
- if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx) {
- agent_panel.update(cx, |panel, cx| {
- panel.new_thread(&NewThread, window, cx);
+ // TODO DL: The reason why the new thread icon button doesn't create a draft item for non-activated workspaces
+ // might be here. We're only calling activate after getting the workspace?
+
+ let draft_id = workspace.update(cx, |workspace, cx| {
+ let panel = workspace.panel::<AgentPanel>(cx)?;
+ let draft_id = panel.update(cx, |panel, cx| {
+ let id = panel.create_draft(window, cx);
+ panel.activate_draft(id, true, window, cx);
+ id
+ });
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ Some(draft_id)
+ });
+
+ if let Some(draft_id) = draft_id {
+ self.active_entry = Some(ActiveEntry::Draft {
+ id: Some(draft_id),
+ workspace: workspace.clone(),
+ });
+ }
+ }
+
+ fn activate_draft(
+ &mut self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(multi_workspace) = self.multi_workspace.upgrade() {
+ multi_workspace.update(cx, |mw, cx| {
+ mw.activate(workspace.clone(), window, cx);
+ });
+ }
+
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.activate_draft(draft_id, true, window, cx);
});
}
- workspace.focus_panel::<AgentPanel>(window, cx);
+ ws.focus_panel::<AgentPanel>(window, cx);
+ });
+
+ self.active_entry = Some(ActiveEntry::Draft {
+ id: Some(draft_id),
+ workspace: workspace.clone(),
});
+
+ self.observe_draft_editor(cx);
+ }
+
+ fn remove_draft(
+ &mut self,
+ draft_id: DraftId,
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ workspace.update(cx, |ws, cx| {
+ if let Some(panel) = ws.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, _cx| {
+ panel.remove_draft(draft_id);
+ });
+ }
+ });
+
+ let was_active = self
+ .active_entry
+ .as_ref()
+ .is_some_and(|e| e.is_active_draft(draft_id));
+
+ if was_active {
+ let mut switched = false;
+ let group_key = workspace.read(cx).project_group_key(cx);
+
+ // Try the nearest draft in the same panel (prefer the
+ // next one in creation order, fall back to the previous).
+ 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());
+ if let Some(&sibling_id) = sibling {
+ self.activate_draft(sibling_id, workspace, window, cx);
+ switched = true;
+ }
+ }
+
+ // No sibling draft — try the first thread in the group.
+ if !switched {
+ let first_thread = self.contents.entries.iter().find_map(|entry| {
+ if let ListEntry::Thread(thread) = entry {
+ if let ThreadEntryWorkspace::Open(ws) = &thread.workspace {
+ if ws.read(cx).project_group_key(cx) == group_key {
+ return Some((thread.metadata.clone(), ws.clone()));
+ }
+ }
+ }
+ None
+ });
+ if let Some((metadata, ws)) = first_thread {
+ self.activate_thread(metadata, &ws, false, window, cx);
+ switched = true;
+ }
+ }
+
+ if !switched {
+ self.active_entry = None;
+ }
+ }
+
+ self.update_entries(cx);
+ }
+
+ /// 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)?;
+ 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())
}
fn active_project_group_key(&self, cx: &App) -> Option<ProjectGroupKey> {
@@ -3996,111 +4097,86 @@ impl Sidebar {
fn render_draft_thread(
&self,
ix: usize,
+ draft_id: Option<DraftId>,
+ key: &ProjectGroupKey,
+ workspace: Option<&Entity<Workspace>>,
is_active: bool,
worktrees: &[WorktreeInfo],
is_selected: bool,
+ can_dismiss: bool,
cx: &mut Context<Self>,
) -> AnyElement {
- let label: SharedString = if is_active {
- self.active_draft_text(cx)
- .unwrap_or_else(|| "New Thread".into())
- } else {
- "New Thread".into()
- };
+ 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());
let id = SharedString::from(format!("draft-thread-btn-{}", ix));
-
- let thread_item = ThreadItem::new(id, label)
- .icon(IconName::Plus)
- .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
- .worktrees(
- worktrees
- .iter()
- .map(|wt| ThreadItemWorktreeInfo {
- name: wt.name.clone(),
- full_path: wt.full_path.clone(),
- highlight_positions: wt.highlight_positions.clone(),
- kind: wt.kind,
- })
- .collect(),
- )
- .selected(true)
- .focused(is_selected)
- .on_click(cx.listener(|this, _, window, cx| {
- if let Some(workspace) = this.active_workspace(cx) {
- if !AgentPanel::is_visible(&workspace, cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.focus_panel::<AgentPanel>(window, cx);
- });
- }
- }
- }));
-
- div()
- .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
- cx.stop_propagation();
+ let worktrees = worktrees
+ .iter()
+ .map(|worktree| ThreadItemWorktreeInfo {
+ name: worktree.name.clone(),
+ full_path: worktree.full_path.clone(),
+ highlight_positions: worktree.highlight_positions.clone(),
+ kind: worktree.kind,
})
- .child(thread_item)
- .into_any_element()
- }
+ .collect();
- fn render_new_thread(
- &self,
- ix: usize,
- key: &ProjectGroupKey,
- worktrees: &[WorktreeInfo],
- workspace: Option<&Entity<Workspace>>,
- is_selected: bool,
- cx: &mut Context<Self>,
- ) -> AnyElement {
- let label: SharedString = DEFAULT_THREAD_TITLE.into();
+ 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 id = SharedString::from(format!("new-thread-btn-{}", ix));
-
- let mut thread_item = ThreadItem::new(id, label)
- .icon(IconName::Plus)
- .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
- .worktrees(
- worktrees
- .iter()
- .map(|wt| ThreadItemWorktreeInfo {
- name: wt.name.clone(),
- full_path: wt.full_path.clone(),
- highlight_positions: wt.highlight_positions.clone(),
- kind: wt.kind,
- })
- .collect(),
- )
- .selected(false)
+ ThreadItem::new(id, label)
+ .icon(IconName::Pencil)
+ .icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4)))
+ .worktrees(worktrees)
+ .selected(is_active)
.focused(is_selected)
+ .hovered(is_hovered)
+ .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
+ if *is_hovered {
+ this.hovered_thread_index = Some(ix);
+ } else if this.hovered_thread_index == Some(ix) {
+ this.hovered_thread_index = None;
+ }
+ cx.notify();
+ }))
.on_click(cx.listener(move |this, _, window, cx| {
- this.selection = None;
- if let Some(workspace) = this.multi_workspace.upgrade().and_then(|mw| {
- mw.read(cx)
- .workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
- }) {
- this.create_new_thread(&workspace, window, cx);
+ if let Some(draft_id) = draft_id {
+ if let Some(workspace) = &workspace_for_click {
+ this.activate_draft(draft_id, workspace, window, cx);
+ }
} else {
+ // Placeholder for a group with no workspace — open it.
this.open_workspace_for_group(&key, window, cx);
}
- }));
-
- // Linked worktree DraftThread entries can be dismissed, which removes
- // the workspace from the multi-workspace.
- if let Some(workspace) = workspace.cloned() {
- thread_item = thread_item.action_slot(
- IconButton::new("close-worktree-workspace", IconName::Close)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text("Close Workspace"))
- .on_click(cx.listener(move |this, _, window, cx| {
- this.remove_worktree_workspace(workspace.clone(), window, cx);
- })),
- );
- }
-
- thread_item.into_any_element()
+ }))
+ .when(can_dismiss && draft_id.is_some(), |this| {
+ let draft_id = draft_id.unwrap();
+ this.action_slot(
+ div()
+ .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation();
+ })
+ .child(
+ IconButton::new(
+ SharedString::from(format!("close-draft-{}", ix)),
+ IconName::Close,
+ )
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Remove Draft"))
+ .on_click(cx.listener(
+ move |this, _, window, cx| {
+ if let Some(workspace) = &workspace_for_remove {
+ this.remove_draft(draft_id, workspace, window, cx);
+ }
+ },
+ )),
+ ),
+ )
+ })
+ .into_any_element()
}
fn render_no_results(&self, cx: &mut Context<Self>) -> impl IntoElement {