mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
sidebar: Add a new thread draft system (#53574)
Release Notes: - N/A --------- Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com> Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
parent
f4477249f2
commit
86f55495c2
7 changed files with 1231 additions and 555 deletions
|
|
@ -56,7 +56,7 @@ use extension_host::ExtensionStore;
|
|||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
|
||||
DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable,
|
||||
DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, Global,
|
||||
KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
|
||||
pulsating_between,
|
||||
};
|
||||
|
|
@ -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,25 @@ 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 within the same app.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct DraftId(pub usize);
|
||||
|
||||
#[derive(Default)]
|
||||
struct DraftIdCounter(usize);
|
||||
|
||||
impl Global for DraftIdCounter {}
|
||||
|
||||
impl DraftId {
|
||||
fn next(cx: &mut App) -> Self {
|
||||
let counter = cx.default_global::<DraftIdCounter>();
|
||||
let id = counter.0;
|
||||
counter.0 += 1;
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum ActiveView {
|
||||
Uninitialized,
|
||||
AgentThread {
|
||||
|
|
@ -803,6 +813,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 +1192,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 +1318,96 @@ impl AgentPanel {
|
|||
}
|
||||
|
||||
pub fn new_thread(&mut self, _action: &NewThread, 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);
|
||||
let id = self.create_draft(window, cx);
|
||||
self.activate_draft(id, 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(cx);
|
||||
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 conversation_view = self.create_agent_thread(
|
||||
server, None, None, None, None, workspace, project, agent, window, cx,
|
||||
);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the message editor text of a tracked draft.
|
||||
pub fn clear_draft_editor(&self, id: DraftId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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(
|
||||
|
|
@ -1410,7 +1509,7 @@ impl AgentPanel {
|
|||
});
|
||||
|
||||
let server = agent.server(fs, thread_store);
|
||||
self.create_agent_thread(
|
||||
let conversation_view = self.create_agent_thread(
|
||||
server,
|
||||
resume_session_id,
|
||||
work_dirs,
|
||||
|
|
@ -1419,6 +1518,11 @@ impl AgentPanel {
|
|||
workspace,
|
||||
project,
|
||||
agent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.set_active_view(
|
||||
ActiveView::AgentThread { conversation_view },
|
||||
focus,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -1982,6 +2086,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;
|
||||
};
|
||||
|
|
@ -2188,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 });
|
||||
}
|
||||
|
|
@ -2528,10 +2648,9 @@ impl AgentPanel {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
agent: Agent,
|
||||
focus: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
) -> Entity<ConversationView> {
|
||||
if self.selected_agent != agent {
|
||||
self.selected_agent = agent.clone();
|
||||
self.serialize(cx);
|
||||
|
|
@ -2586,12 +2705,7 @@ impl AgentPanel {
|
|||
})
|
||||
.detach();
|
||||
|
||||
self.set_active_view(
|
||||
ActiveView::AgentThread { conversation_view },
|
||||
focus,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
conversation_view
|
||||
}
|
||||
|
||||
fn active_thread_has_messages(&self, cx: &App) -> bool {
|
||||
|
|
@ -3457,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4745,8 +4859,14 @@ impl AgentPanel {
|
|||
id: server.agent_id(),
|
||||
};
|
||||
|
||||
self.create_agent_thread(
|
||||
server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
|
||||
let conversation_view = self.create_agent_thread(
|
||||
server, None, None, None, None, workspace, project, ext_agent, window, cx,
|
||||
);
|
||||
self.set_active_view(
|
||||
ActiveView::AgentThread { conversation_view },
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,11 +65,11 @@ use std::any::TypeId;
|
|||
use workspace::Workspace;
|
||||
|
||||
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
|
||||
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, WorktreeCreationStatus};
|
||||
pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, DraftId, WorktreeCreationStatus};
|
||||
use crate::agent_registry_ui::AgentRegistryPage;
|
||||
pub use crate::inline_assistant::InlineAssistant;
|
||||
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
|
||||
pub(crate) use conversation_view::ConversationView;
|
||||
pub use conversation_view::ConversationView;
|
||||
pub use external_source_prompt::ExternalSourcePrompt;
|
||||
pub(crate) use mode_selector::ModeSelector;
|
||||
pub(crate) use model_selector::ModelSelector;
|
||||
|
|
|
|||
|
|
@ -342,9 +342,9 @@ impl Render for ThreadImportModal {
|
|||
Modal::new("import-threads", None)
|
||||
.header(
|
||||
ModalHeader::new()
|
||||
.headline("Import ACP Threads")
|
||||
.headline("Import External Agent Threads")
|
||||
.description(
|
||||
"Import threads from your ACP agents — whether started in Zed or another client. \
|
||||
"Import threads from agents like Claude Agent, Codex, and more, whether started in Zed or another client. \
|
||||
Choose which agents to include, and their threads will appear in your archive."
|
||||
)
|
||||
.show_dismiss_button(true),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -553,7 +553,6 @@ impl ThreadsArchiveView {
|
|||
base.status(AgentThreadStatus::Running)
|
||||
.action_slot(
|
||||
IconButton::new("cancel-restore", IconName::Close)
|
||||
.style(ButtonStyle::Filled)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Cancel Restore"))
|
||||
|
|
@ -568,12 +567,11 @@ impl ThreadsArchiveView {
|
|||
})
|
||||
}),
|
||||
)
|
||||
.tooltip(Tooltip::text("Restoring\u{2026}"))
|
||||
.tooltip(Tooltip::text("Restoring…"))
|
||||
.into_any_element()
|
||||
} else {
|
||||
base.action_slot(
|
||||
IconButton::new("delete-thread", IconName::Trash)
|
||||
.style(ButtonStyle::Filled)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -45,7 +45,7 @@ fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &st
|
|||
#[track_caller]
|
||||
fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == workspace),
|
||||
"{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
|
||||
workspace.entity_id(),
|
||||
sidebar.active_entry,
|
||||
|
|
@ -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, ..
|
||||
|
|
@ -388,17 +383,14 @@ fn visible_entries_as_strings(
|
|||
format!(" + View More{}", selected)
|
||||
}
|
||||
}
|
||||
ListEntry::DraftThread {
|
||||
workspace,
|
||||
worktrees,
|
||||
..
|
||||
} => {
|
||||
ListEntry::DraftThread { worktrees, .. } => {
|
||||
let worktree = format_linked_worktree_chips(worktrees);
|
||||
if workspace.is_some() {
|
||||
format!(" [+ New Thread{}]{}", worktree, selected)
|
||||
} else {
|
||||
format!(" [~ Draft{}]{}{}", worktree, active_indicator, selected)
|
||||
}
|
||||
let is_active = sidebar
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.matches_entry(entry));
|
||||
let active_marker = if is_active { " *" } else { "" };
|
||||
format!(" [~ Draft{}]{}{}", worktree, active_marker, selected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -566,10 +558,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
|
|||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
]
|
||||
vec!["v [my-project]", " [~ Draft]"]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1329,13 +1318,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
|
|||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
// An empty project has only the header.
|
||||
// An empty project has the header and an auto-created draft.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [empty-project]",
|
||||
]
|
||||
vec!["v [empty-project]", " [~ Draft]"]
|
||||
);
|
||||
|
||||
// Focus sidebar — focus_in does not set a selection
|
||||
|
|
@ -1346,7 +1332,11 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
|
|||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
|
||||
|
||||
// At the end (only one entry), wraps back to first entry
|
||||
// SelectNext advances to index 1 (draft entry)
|
||||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
|
||||
|
||||
// At the end (two entries), wraps back to first entry
|
||||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
|
||||
|
||||
|
|
@ -1470,7 +1460,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
|
|||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" Hello * (active)",
|
||||
" Hello *",
|
||||
" Hello * (running)",
|
||||
]
|
||||
);
|
||||
|
|
@ -1568,7 +1558,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
|
|||
vec![
|
||||
//
|
||||
"v [project-a]",
|
||||
" Hello * (running) (active)",
|
||||
" Hello * (running)",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1582,7 +1572,7 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
|
|||
vec![
|
||||
//
|
||||
"v [project-a]",
|
||||
" Hello * (!) (active)",
|
||||
" Hello * (!)",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -2274,7 +2264,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
|
|||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" Hello * (active)",
|
||||
" Hello *",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -2300,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 *",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -2558,7 +2548,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
|||
vec![
|
||||
//
|
||||
"v [project-a]",
|
||||
" Hello * (active)",
|
||||
" Hello *",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -2591,9 +2581,8 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
|||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [project-a, project-b]",
|
||||
" Hello * (active)",
|
||||
"v [project-a, project-b]", //
|
||||
" Hello *",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -3126,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",
|
||||
|
|
@ -3207,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",
|
||||
|
|
@ -3327,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",
|
||||
]
|
||||
|
|
@ -3386,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",
|
||||
]
|
||||
|
|
@ -3421,7 +3406,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
|
|||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" Hello * (active)",
|
||||
" Hello *",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -3437,12 +3422,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
|
|||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" [~ Draft] (active)",
|
||||
" Hello *",
|
||||
],
|
||||
vec!["v [my-project]", " [~ Draft] *", " Hello *"],
|
||||
"After Cmd-N the sidebar should show a highlighted Draft entry"
|
||||
);
|
||||
|
||||
|
|
@ -3478,25 +3458,20 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
|
|||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" Hello * (active)",
|
||||
" Hello *",
|
||||
]
|
||||
);
|
||||
|
||||
// Open a new draft thread via a server connection. This gives the
|
||||
// conversation a parent_id (session assigned by the server) but
|
||||
// no messages have been sent, so active_thread_is_draft() is true.
|
||||
let draft_connection = StubAgentConnection::new();
|
||||
open_thread_with_connection(&panel, draft_connection, cx);
|
||||
// Create a new draft via Cmd-N. Since new_thread() now creates a
|
||||
// tracked draft in the AgentPanel, it appears in the sidebar.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [my-project]",
|
||||
" [~ Draft] (active)",
|
||||
" Hello *",
|
||||
],
|
||||
vec!["v [my-project]", " [~ Draft] *", " Hello *"],
|
||||
);
|
||||
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
|
||||
|
|
@ -3509,6 +3484,80 @@ 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");
|
||||
});
|
||||
|
||||
// Simulate what happens when a draft sends its first message:
|
||||
// the AgentPanel's MessageSentOrQueued handler removes the draft
|
||||
// from `draft_threads`, then the sidebar rebuilds. We can't use
|
||||
// the NativeAgentServer in tests, so replicate the key steps:
|
||||
// remove the draft, open a real thread with a stub connection,
|
||||
// and send.
|
||||
let draft_id = panel.read_with(cx, |panel, _| panel.active_draft_id().unwrap());
|
||||
panel.update_in(cx, |panel, _window, _cx| {
|
||||
panel.remove_draft(draft_id);
|
||||
});
|
||||
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
|
||||
|
|
@ -3593,7 +3642,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} *",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -3611,8 +3660,8 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
|
|||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" [~ Draft {wt-feature-a}] (active)",
|
||||
" Hello {wt-feature-a} *",
|
||||
" [~ Draft {wt-feature-a}] *",
|
||||
" Hello {wt-feature-a} *"
|
||||
],
|
||||
"After Cmd-N in an absorbed worktree, the sidebar should show \
|
||||
a highlighted Draft entry under the main repo header"
|
||||
|
|
@ -3729,11 +3778,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
|
|||
// The chip name is derived from the path even before git discovery.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" Worktree Thread {rosewood}",
|
||||
]
|
||||
vec!["v [project]", " Worktree Thread {rosewood}"]
|
||||
);
|
||||
|
||||
// Now add the worktree to the git state and trigger a rescan.
|
||||
|
|
@ -3925,12 +3970,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
|
|||
// appears as a "New Thread" button with its worktree chip.
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" [+ New Thread {wt-feature-b}]",
|
||||
" Thread A {wt-feature-a}",
|
||||
]
|
||||
vec!["v [project]", " Thread A {wt-feature-a}",]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4184,12 +4224,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
|
|||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" [~ Draft] (active)",
|
||||
" Hello {wt-feature-a} * (running)",
|
||||
]
|
||||
vec!["v [project]", " Hello {wt-feature-a} * (running)",]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4272,12 +4307,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
|
|||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" [~ Draft] (active)",
|
||||
" Hello {wt-feature-a} * (running)",
|
||||
]
|
||||
vec!["v [project]", " Hello {wt-feature-a} * (running)",]
|
||||
);
|
||||
|
||||
connection.end_turn(session_id, acp::StopReason::EndTurn);
|
||||
|
|
@ -4285,12 +4315,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
|
|||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec![
|
||||
//
|
||||
"v [project]",
|
||||
" [~ Draft] (active)",
|
||||
" Hello {wt-feature-a} * (!)",
|
||||
]
|
||||
vec!["v [project]", " Hello {wt-feature-a} * (!)",]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -5498,6 +5523,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
|
|||
vec![
|
||||
//
|
||||
"v [other, project]",
|
||||
" [~ Draft]",
|
||||
"v [project]",
|
||||
" Worktree Thread {wt-feature-a}",
|
||||
]
|
||||
|
|
@ -5931,6 +5957,12 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
|||
let panel_b = add_agent_panel(&workspace_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Explicitly create a draft on workspace_b so the sidebar tracks one.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.create_new_thread(&workspace_b, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// --- Scenario 1: archive a thread in the non-active workspace ---
|
||||
|
||||
// Create a thread in project-a (non-active — project-b is active).
|
||||
|
|
@ -5951,7 +5983,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
|||
// active_entry should still be a draft on workspace_b (the active one).
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
|
|
@ -5986,7 +6018,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
|||
// Should fall back to a draft on the same workspace.
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
|
|
@ -5996,9 +6028,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);
|
||||
|
|
@ -6059,7 +6090,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut Test
|
|||
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == &workspace_a),
|
||||
"expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
|
|
@ -6561,9 +6592,10 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
|
|||
#[gpui::test]
|
||||
async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
|
||||
// When a linked worktree is opened as its own workspace and the user
|
||||
// switches away, the workspace must still be reachable from a DraftThread
|
||||
// sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
|
||||
// entry should remove the workspace.
|
||||
// creates a draft thread from it, then switches away, the workspace must
|
||||
// still be reachable from that DraftThread sidebar entry. Pressing
|
||||
// RemoveSelectedThread (shift-backspace) on that entry should remove the
|
||||
// workspace.
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
|
|
@ -6627,6 +6659,14 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
|
|||
add_agent_panel(&worktree_workspace, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Explicitly create a draft thread from the linked worktree workspace.
|
||||
// Auto-created drafts use the group's first workspace (the main one),
|
||||
// so a user-created draft is needed to make the linked worktree reachable.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.create_new_thread(&worktree_workspace, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Switch back to the main workspace.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
let main_ws = mw.workspaces().next().unwrap().clone();
|
||||
|
|
@ -6656,7 +6696,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
|
|||
"linked worktree workspace should be reachable, but reachable are: {reachable:?}"
|
||||
);
|
||||
|
||||
// Find the DraftThread entry for the linked worktree and dismiss it.
|
||||
// Find the DraftThread entry whose workspace is the linked worktree.
|
||||
let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
|
||||
sidebar
|
||||
.contents
|
||||
|
|
@ -6666,9 +6706,9 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
|
|||
matches!(
|
||||
entry,
|
||||
ListEntry::DraftThread {
|
||||
workspace: Some(_),
|
||||
workspace: Some(ws),
|
||||
..
|
||||
}
|
||||
} if ws.entity_id() == worktree_ws_id
|
||||
)
|
||||
})
|
||||
.expect("expected a DraftThread entry for the linked worktree")
|
||||
|
|
@ -6687,8 +6727,25 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
|
|||
|
||||
assert_eq!(
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
|
||||
1,
|
||||
"linked worktree workspace should be removed after dismissing DraftThread entry"
|
||||
2,
|
||||
"dismissing a draft no longer removes the linked worktree workspace"
|
||||
);
|
||||
|
||||
let has_draft_for_worktree = sidebar.read_with(cx, |sidebar, _| {
|
||||
sidebar.contents.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ListEntry::DraftThread {
|
||||
draft_id: Some(_),
|
||||
workspace: Some(ws),
|
||||
..
|
||||
} if ws.entity_id() == worktree_ws_id
|
||||
)
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
!has_draft_for_worktree,
|
||||
"DraftThread entry for the linked worktree should be removed after dismiss"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -7226,6 +7283,372 @@ 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();
|
||||
|
||||
// Explicitly create a draft on workspace_b so the sidebar tracks one.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.create_new_thread(&workspace_b, window, 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::*;
|
||||
|
|
@ -7462,8 +7885,9 @@ mod property_test {
|
|||
let panel =
|
||||
workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
|
||||
if let Some(panel) = panel {
|
||||
let connection = StubAgentConnection::new();
|
||||
open_thread_with_connection(&panel, connection, cx);
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
|
|
@ -7880,11 +8304,29 @@ mod property_test {
|
|||
|
||||
let active_workspace = multi_workspace.read(cx).workspace();
|
||||
|
||||
// 1. active_entry must always be Some after rebuild_contents.
|
||||
let entry = sidebar
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
|
||||
// 1. active_entry should be Some when the panel has content.
|
||||
// It may be None when the panel is uninitialized (no drafts,
|
||||
// no threads), which is fine.
|
||||
// It may also temporarily point at a different workspace
|
||||
// when the workspace just changed and the new panel has no
|
||||
// content yet.
|
||||
let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
|
||||
let panel_has_content = panel.read(cx).active_draft_id().is_some()
|
||||
|| panel.read(cx).active_conversation_view().is_some();
|
||||
|
||||
let Some(entry) = sidebar.active_entry.as_ref() else {
|
||||
if panel_has_content {
|
||||
anyhow::bail!("active_entry is None but panel has content (draft or thread)");
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// If the entry workspace doesn't match the active workspace
|
||||
// and the panel has no content, this is a transient state that
|
||||
// will resolve when the panel gets content.
|
||||
if entry.workspace().entity_id() != active_workspace.entity_id() && !panel_has_content {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. The entry's workspace must agree with the multi-workspace's
|
||||
// active workspace.
|
||||
|
|
@ -7896,11 +8338,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 {:?}",
|
||||
matches!(entry, ActiveEntry::Draft { .. }),
|
||||
"panel shows a tracked draft but active_entry is {:?}",
|
||||
entry,
|
||||
);
|
||||
} else if let Some(session_id) = panel
|
||||
|
|
|
|||
Loading…
Reference in a new issue