diff --git a/Cargo.lock b/Cargo.lock index 11a71ff5933670b7781e498c5d5acd5f22fb240e..5d6cdb83cfb8638f87b7c1e621651f756f214239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "buffer_diff", + "chrono", "collections", "editor", "env_logger 0.11.8", @@ -428,6 +429,7 @@ dependencies = [ name = "agent_ui_v2" version = "0.1.0" dependencies = [ + "acp_thread", "agent", "agent-client-protocol", "agent_servers", @@ -441,6 +443,7 @@ dependencies = [ "fs", "fuzzy", "gpui", + "log", "menu", "project", "prompt_store", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index aa36e6d9e550306eeaa94e5b171b20a14cb88187..11ce57224664d75798a5f5f08e09017661a0a6fb 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -22,6 +22,7 @@ base64.workspace = true agent_settings.workspace = true anyhow.workspace = true buffer_diff.workspace = true +chrono.workspace = true collections.workspace = true editor.workspace = true file_icons.workspace = true diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 26b9d957be067ea05999b31935a7761f905811a9..f0fdc629e78fdea07ee8d4e88cb9a1184986e37f 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,12 +1,20 @@ use crate::AcpThread; use agent_client_protocol::{self as acp}; use anyhow::Result; +use chrono::{DateTime, Utc}; use collections::IndexMap; use gpui::{Entity, SharedString, Task}; use language_model::LanguageModelProviderId; use project::Project; use serde::{Deserialize, Serialize}; -use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc}; +use std::{ + any::Any, + error::Error, + fmt, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; use ui::{App, IconName}; use uuid::Uuid; @@ -94,6 +102,10 @@ pub trait AgentConnection { None } + fn session_list(&self, _cx: &mut App) -> Option> { + None + } + fn into_any(self: Rc) -> Rc; } @@ -153,6 +165,79 @@ pub trait AgentSessionConfigOptions { } } +#[derive(Debug, Clone, Default)] +pub struct AgentSessionListRequest { + pub cwd: Option, + pub cursor: Option, + pub meta: Option, +} + +#[derive(Debug, Clone)] +pub struct AgentSessionListResponse { + pub sessions: Vec, + pub next_cursor: Option, + pub meta: Option, +} + +impl AgentSessionListResponse { + pub fn new(sessions: Vec) -> Self { + Self { + sessions, + next_cursor: None, + meta: None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentSessionInfo { + pub session_id: acp::SessionId, + pub cwd: Option, + pub title: Option, + pub updated_at: Option>, + pub meta: Option, +} + +impl AgentSessionInfo { + pub fn new(session_id: impl Into) -> Self { + Self { + session_id: session_id.into(), + cwd: None, + title: None, + updated_at: None, + meta: None, + } + } +} + +pub trait AgentSessionList { + fn list_sessions( + &self, + request: AgentSessionListRequest, + cx: &mut App, + ) -> Task>; + + fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + + fn delete_sessions(&self, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + + fn watch(&self, _cx: &mut App) -> Option> { + None + } + + fn into_any(self: Rc) -> Rc; +} + +impl dyn AgentSessionList { + pub fn downcast(self: Rc) -> Option> { + self.into_any().downcast().ok() + } +} + #[derive(Debug)] pub struct AuthRequired { pub description: Option, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index e3c3ee1cd5beef4358e2dfbeb50146a45ee99063..f66054feb311d6830eb62a6714df2db36619d6b4 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -20,7 +20,10 @@ pub use thread_store::*; pub use tool_permissions::*; pub use tools::*; -use acp_thread::{AcpThread, AgentModelSelector, UserMessageId}; +use acp_thread::{ + AcpThread, AgentModelSelector, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, + AgentSessionListResponse, UserMessageId, +}; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; @@ -1345,6 +1348,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) as _) } + fn session_list(&self, cx: &mut App) -> Option> { + let thread_store = self.0.read(cx).thread_store.clone(); + Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _) + } + fn telemetry(&self) -> Option> { Some(Rc::new(self.clone()) as Rc) } @@ -1371,6 +1379,74 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection { } } +pub struct NativeAgentSessionList { + thread_store: Entity, + updates_rx: watch::Receiver<()>, + _subscription: Subscription, +} + +impl NativeAgentSessionList { + fn new(thread_store: Entity, cx: &mut App) -> Self { + let (mut tx, rx) = watch::channel(()); + let subscription = cx.observe(&thread_store, move |_, _| { + tx.send(()).ok(); + }); + Self { + thread_store, + updates_rx: rx, + _subscription: subscription, + } + } + + fn to_session_info(entry: DbThreadMetadata) -> AgentSessionInfo { + AgentSessionInfo { + session_id: entry.id, + cwd: None, + title: Some(entry.title), + updated_at: Some(entry.updated_at), + meta: None, + } + } + + pub fn thread_store(&self) -> &Entity { + &self.thread_store + } +} + +impl AgentSessionList for NativeAgentSessionList { + fn list_sessions( + &self, + _request: AgentSessionListRequest, + cx: &mut App, + ) -> Task> { + let sessions = self + .thread_store + .read(cx) + .entries() + .map(Self::to_session_info) + .collect(); + Task::ready(Ok(AgentSessionListResponse::new(sessions))) + } + + fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + self.thread_store + .update(cx, |store, cx| store.delete_thread(session_id.clone(), cx)) + } + + fn delete_sessions(&self, cx: &mut App) -> Task> { + self.thread_store + .update(cx, |store, cx| store.delete_threads(cx)) + } + + fn watch(&self, _cx: &mut App) -> Option> { + Some(self.updates_rx.clone()) + } + + fn into_any(self: Rc) -> Rc { + self + } +} + struct NativeAgentSessionTruncate { thread: Entity, acp_thread: WeakEntity, diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 22a4eb4f85e3781fbdde034bcb39655d017e7578..d023a71d7bd7343f6aefd3094d6a27705f193dc6 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -1,6 +1,6 @@ use std::{cell::RefCell, ops::Range, rc::Rc}; -use acp_thread::{AcpThread, AgentThreadEntry}; +use acp_thread::{AcpThread, AgentSessionList, AgentThreadEntry}; use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; @@ -23,7 +23,8 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + thread_store: Option>, + session_list: Rc>>>, prompt_store: Option>, entries: Vec, prompt_capabilities: Rc>, @@ -35,7 +36,8 @@ impl EntryViewState { pub fn new( workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + thread_store: Option>, + session_list: Rc>>>, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -44,7 +46,8 @@ impl EntryViewState { Self { workspace, project, - history_store, + thread_store, + session_list, prompt_store, entries: Vec::new(), prompt_capabilities, @@ -85,7 +88,8 @@ impl EntryViewState { let mut editor = MessageEditor::new( self.workspace.clone(), self.project.clone(), - self.history_store.clone(), + self.thread_store.clone(), + self.session_list.clone(), self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), @@ -396,10 +400,9 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { #[cfg(test)] mod tests { - use std::{path::Path, rc::Rc}; + use std::{cell::RefCell, path::Path, rc::Rc}; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent::ThreadStore; use agent_client_protocol as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; @@ -451,13 +454,15 @@ mod tests { connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) }); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), project.downgrade(), - history_store, + thread_store, + session_list, None, Default::default(), Default::default(), diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index acbf05a77c9a1b2af711b253050fe9bc40d458b3..bd020529581256b950cb6d11976edb2cb8544664 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -9,7 +9,7 @@ use crate::{ Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, }, }; -use acp_thread::MentionUri; +use acp_thread::{AgentSessionInfo, AgentSessionList, MentionUri}; use agent::ThreadStore; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; @@ -44,6 +44,7 @@ pub struct MessageEditor { prompt_capabilities: Rc>, available_commands: Rc>>, agent_name: SharedString, + thread_store: Option>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, } @@ -69,11 +70,10 @@ impl PromptCompletionProviderDelegate for Entity { fn supported_modes(&self, cx: &App) -> Vec { let mut supported = vec![PromptContextType::File, PromptContextType::Symbol]; if self.read(cx).prompt_capabilities.borrow().embedded_context { - supported.extend(&[ - PromptContextType::Thread, - PromptContextType::Fetch, - PromptContextType::Rules, - ]); + if self.read(cx).thread_store.is_some() { + supported.push(PromptContextType::Thread); + } + supported.extend(&[PromptContextType::Fetch, PromptContextType::Rules]); } supported } @@ -100,7 +100,8 @@ impl MessageEditor { pub fn new( workspace: WeakEntity, project: WeakEntity, - history_store: Entity, + thread_store: Option>, + session_list: Rc>>>, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -152,12 +153,13 @@ impl MessageEditor { editor }); let mention_set = - cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); + cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); let completion_provider = Rc::new(PromptCompletionProvider::new( cx.entity(), editor.downgrade(), mention_set.clone(), - history_store.clone(), + thread_store.clone(), + session_list, prompt_store.clone(), workspace.clone(), )); @@ -215,6 +217,7 @@ impl MessageEditor { prompt_capabilities, available_commands, agent_name, + thread_store, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), } @@ -269,16 +272,24 @@ impl MessageEditor { pub fn insert_thread_summary( &mut self, - thread: agent::DbThreadMetadata, + thread: AgentSessionInfo, window: &mut Window, cx: &mut Context, ) { + if self.thread_store.is_none() { + return; + } let Some(workspace) = self.workspace.upgrade() else { return; }; + let thread_title = thread + .title + .clone() + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| SharedString::new_static("New Thread")); let uri = MentionUri::Thread { - id: thread.id.clone(), - name: thread.title.to_string(), + id: thread.session_id, + name: thread_title.to_string(), }; let content = format!("{}\n", uri.as_link()); @@ -299,7 +310,7 @@ impl MessageEditor { self.mention_set .update(cx, |mention_set, cx| { mention_set.confirm_mention_completion( - thread.title, + thread_title, start, content_len, uri, @@ -1061,7 +1072,7 @@ impl Addon for MessageEditorAddon { mod tests { use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc}; - use acp_thread::MentionUri; + use acp_thread::{AgentSessionInfo, MentionUri}; use agent::{ThreadStore, outline}; use agent_client_protocol as acp; use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset}; @@ -1083,6 +1094,7 @@ mod tests { message_editor::{Mention, MessageEditor}, thread_view::tests::init_test, }; + use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType}; #[gpui::test] async fn test_at_mention_removal(cx: &mut TestAppContext) { @@ -1095,14 +1107,16 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), @@ -1199,7 +1213,8 @@ mod tests { .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); // Start with no available commands - simulating Claude which doesn't support slash commands let available_commands = Rc::new(RefCell::new(vec![])); @@ -1212,7 +1227,8 @@ mod tests { MessageEditor::new( workspace_handle.clone(), project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, prompt_capabilities.clone(), available_commands.clone(), @@ -1355,7 +1371,8 @@ mod tests { let mut cx = VisualTestContext::from_window(*window, cx); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -1372,7 +1389,8 @@ mod tests { MessageEditor::new( workspace_handle, project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, prompt_capabilities.clone(), available_commands.clone(), @@ -1584,7 +1602,8 @@ mod tests { opened_editors.push(buffer); } - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let session_list = Rc::new(RefCell::new(None)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1593,7 +1612,8 @@ mod tests { MessageEditor::new( workspace_handle, project.downgrade(), - history_store.clone(), + Some(thread_store), + session_list.clone(), None, prompt_capabilities.clone(), Default::default(), @@ -2076,14 +2096,16 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { let editor = MessageEditor::new( workspace.downgrade(), project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), @@ -2173,13 +2195,16 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); // Create a thread metadata to insert as summary - let thread_metadata = agent::DbThreadMetadata { - id: acp::SessionId::new("thread-123"), - title: "Previous Conversation".into(), - updated_at: chrono::Utc::now(), + let thread_metadata = AgentSessionInfo { + session_id: acp::SessionId::new("thread-123"), + cwd: None, + title: Some("Previous Conversation".into()), + updated_at: Some(chrono::Utc::now()), + meta: None, }; let message_editor = cx.update(|window, cx| { @@ -2187,7 +2212,8 @@ mod tests { let mut editor = MessageEditor::new( workspace.downgrade(), project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), @@ -2207,10 +2233,11 @@ mod tests { // Construct expected values for verification let expected_uri = MentionUri::Thread { - id: thread_metadata.id.clone(), - name: thread_metadata.title.to_string(), + id: thread_metadata.session_id.clone(), + name: thread_metadata.title.as_ref().unwrap().to_string(), }; - let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri()); + let expected_title = thread_metadata.title.as_ref().unwrap(); + let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri()); message_editor.read_with(cx, |editor, cx| { let text = editor.text(cx); @@ -2236,6 +2263,171 @@ mod tests { }); } + #[gpui::test] + async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) { + init_test(cx); + cx.update(LanguageModelRegistry::test); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); + + let thread_metadata = AgentSessionInfo { + session_id: acp::SessionId::new("thread-123"), + cwd: None, + title: Some("Previous Conversation".into()), + updated_at: Some(chrono::Utc::now()), + meta: None, + }; + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + let mut editor = MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + thread_store.clone(), + session_list.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + editor.insert_thread_summary(thread_metadata, window, cx); + editor + }) + }); + + message_editor.read_with(cx, |editor, cx| { + assert!( + editor.text(cx).is_empty(), + "Expected thread summary to be skipped for external agents" + ); + assert!( + editor.mention_set().read(cx).mentions().is_empty(), + "Expected no mentions when thread summary is skipped" + ); + }); + } + + #[gpui::test] + async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let thread_store = None; + let session_list = Rc::new(RefCell::new(None)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + thread_store.clone(), + session_list.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + + message_editor.update(cx, |editor, _cx| { + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); + }); + + let supported_modes = { + let app = cx.app.borrow(); + message_editor.supported_modes(&app) + }; + + assert!( + !supported_modes.contains(&PromptContextType::Thread), + "Expected thread mode to be hidden when thread mentions are disabled" + ); + } + + #[gpui::test] + async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); + + let message_editor = cx.update(|window, cx| { + cx.new(|cx| { + MessageEditor::new( + workspace.downgrade(), + project.downgrade(), + thread_store.clone(), + session_list.clone(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ) + }) + }); + + message_editor.update(cx, |editor, _cx| { + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); + }); + + let supported_modes = { + let app = cx.app.borrow(); + message_editor.supported_modes(&app) + }; + + assert!( + supported_modes.contains(&PromptContextType::Thread), + "Expected thread mode to be visible when enabled" + ); + } + #[gpui::test] async fn test_whitespace_trimming(cx: &mut TestAppContext) { init_test(cx); @@ -2248,14 +2440,16 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); let message_editor = cx.update(|window, cx| { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), @@ -2309,7 +2503,8 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -2317,7 +2512,8 @@ mod tests { MessageEditor::new( workspace_handle, project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), @@ -2463,7 +2659,8 @@ mod tests { }); }); - let history_store = cx.new(|cx| ThreadStore::new(cx)); + let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); + let session_list = Rc::new(RefCell::new(None)); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -2474,7 +2671,8 @@ mod tests { MessageEditor::new( workspace_handle, project.downgrade(), - history_store.clone(), + thread_store.clone(), + session_list.clone(), None, Default::default(), Default::default(), diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 4a380bb4e79583ec9e3883bbb5a0f36e10c63790..b5fa4af7cc9eee3b0c1607e579923cd0f01a0f48 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,6 +1,7 @@ use crate::acp::AcpThreadView; use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; -use agent::{DbThreadMetadata, ThreadStore}; +use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest}; +use agent_client_protocol as acp; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; @@ -8,7 +9,7 @@ use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, }; -use std::{fmt::Display, ops::Range}; +use std::{fmt::Display, ops::Range, rc::Rc}; use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ @@ -18,16 +19,17 @@ use ui::{ const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); -fn thread_title(entry: &DbThreadMetadata) -> &SharedString { - if entry.title.is_empty() { - DEFAULT_TITLE - } else { - &entry.title - } +fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) } pub struct AcpThreadHistory { - pub(crate) thread_store: Entity, + session_list: Option>, + sessions: Vec, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option, @@ -37,23 +39,24 @@ pub struct AcpThreadHistory { local_timezone: UtcOffset, confirming_delete_history: bool, _update_task: Task<()>, + _watch_task: Option>, _subscriptions: Vec, } enum ListItemType { BucketSeparator(TimeBucket), Entry { - entry: DbThreadMetadata, + entry: AgentSessionInfo, format: EntryTimeFormat, }, SearchResult { - entry: DbThreadMetadata, + entry: AgentSessionInfo, positions: Vec, }, } impl ListItemType { - fn history_entry(&self) -> Option<&DbThreadMetadata> { + fn history_entry(&self) -> Option<&AgentSessionInfo> { match self { ListItemType::Entry { entry, .. } => Some(entry), ListItemType::SearchResult { entry, .. } => Some(entry), @@ -63,14 +66,14 @@ impl ListItemType { } pub enum ThreadHistoryEvent { - Open(DbThreadMetadata), + Open(AgentSessionInfo), } impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { pub(crate) fn new( - thread_store: Entity, + session_list: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -91,14 +94,11 @@ impl AcpThreadHistory { } }); - let thread_store_subscription = cx.observe(&thread_store, |this, _, cx| { - this.update_visible_items(true, cx); - }); - let scroll_handle = UniformListScrollHandle::default(); let mut this = Self { - thread_store, + session_list: None, + sessions: Vec::new(), scroll_handle, selected_index: 0, hovered_index: None, @@ -110,17 +110,16 @@ impl AcpThreadHistory { .unwrap(), search_query: SharedString::default(), confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription, thread_store_subscription], + _subscriptions: vec![search_editor_subscription], _update_task: Task::ready(()), + _watch_task: None, }; - this.update_visible_items(false, cx); + this.set_session_list(session_list, cx); this } fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self - .thread_store - .update(cx, |store, _| store.entries().collect()); + let entries = self.sessions.clone(); let new_list_items = if self.search_query.is_empty() { self.add_list_separators(entries, cx) } else { @@ -141,7 +140,7 @@ impl AcpThreadHistory { .position(|visible_entry| { visible_entry .history_entry() - .is_some_and(|entry| entry.id == history_entry.id) + .is_some_and(|entry| entry.session_id == history_entry.session_id) }) .unwrap_or(0) } else { @@ -156,9 +155,111 @@ impl AcpThreadHistory { }); } + pub(crate) fn set_session_list( + &mut self, + session_list: Option>, + cx: &mut Context, + ) { + if let (Some(current), Some(next)) = (&self.session_list, &session_list) + && Rc::ptr_eq(current, next) + { + return; + } + + self.session_list = session_list; + self.sessions.clear(); + self.visible_items.clear(); + self.selected_index = 0; + self.refresh_sessions(false, cx); + + self._watch_task = self.session_list.as_ref().and_then(|session_list| { + let mut rx = session_list.watch(cx)?; + Some(cx.spawn(async move |this, cx| { + while let Ok(()) = rx.recv().await { + this.update(cx, |this, cx| { + this.refresh_sessions(true, cx); + }) + .ok(); + } + })) + }); + } + + fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let Some(session_list) = self.session_list.clone() else { + self.update_visible_items(preserve_selected_item, cx); + return; + }; + + self._update_task = cx.spawn(async move |this, cx| { + let mut cursor: Option = None; + let mut is_first_page = true; + + loop { + let request = AgentSessionListRequest { + cursor: cursor.clone(), + ..Default::default() + }; + let task = cx.update(|cx| session_list.list_sessions(request, cx)); + let response = match task.await { + Ok(response) => response, + Err(error) => { + log::error!("Failed to load session history: {error:#}"); + return; + } + }; + + let acp_thread::AgentSessionListResponse { + sessions: page_sessions, + next_cursor, + .. + } = response; + + this.update(cx, |this, cx| { + if is_first_page { + this.sessions = page_sessions; + } else { + this.sessions.extend(page_sessions); + } + this.update_visible_items(preserve_selected_item, cx); + }) + .ok(); + + is_first_page = false; + match next_cursor { + Some(next_cursor) => { + if cursor.as_ref() == Some(&next_cursor) { + log::warn!( + "Session list pagination returned the same cursor; stopping to avoid a loop." + ); + break; + } + cursor = Some(next_cursor); + } + None => break, + } + } + }); + } + + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option { + self.sessions + .iter() + .find(|entry| &entry.session_id == session_id) + .cloned() + } + + pub(crate) fn sessions(&self) -> &[AgentSessionInfo] { + &self.sessions + } + fn add_list_separators( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { cx.background_spawn(async move { @@ -167,8 +268,13 @@ impl AcpThreadHistory { let today = Local::now().naive_local().date(); for entry in entries.into_iter() { - let entry_date = entry.updated_at.with_timezone(&Local).naive_local().date(); - let entry_bucket = TimeBucket::from_dates(today, entry_date); + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); @@ -186,7 +292,7 @@ impl AcpThreadHistory { fn filter_search_results( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { let query = self.search_query.clone(); @@ -227,11 +333,11 @@ impl AcpThreadHistory { self.visible_items.is_empty() && !self.search_query.is_empty() } - fn selected_history_entry(&self) -> Option<&DbThreadMetadata> { + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { self.get_history_entry(self.selected_index) } - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&DbThreadMetadata> { + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { self.visible_items.get(visible_items_ix)?.history_entry() } @@ -330,17 +436,17 @@ impl AcpThreadHistory { let Some(entry) = self.get_history_entry(visible_item_ix) else { return; }; - - let task = self - .thread_store - .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); + let Some(session_list) = self.session_list.as_ref() else { + return; + }; + let task = session_list.delete_session(&entry.session_id, cx); task.detach_and_log_err(cx); } fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.thread_store.update(cx, |store, cx| { - store.delete_threads(cx).detach_and_log_err(cx) - }); + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx).detach_and_log_err(cx); + } self.confirming_delete_history = false; cx.notify(); } @@ -397,7 +503,7 @@ impl AcpThreadHistory { fn render_history_entry( &self, - entry: &DbThreadMetadata, + entry: &AgentSessionInfo, format: EntryTimeFormat, ix: usize, highlight_positions: Vec, @@ -405,23 +511,27 @@ impl AcpThreadHistory { ) -> AnyElement { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; - let timestamp = entry.updated_at.timestamp(); - - let display_text = match format { - EntryTimeFormat::DateAndTime => { - let entry_time = entry.updated_at; + let entry_time = entry.updated_at; + let display_text = match (format, entry_time) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { let now = Utc::now(); let duration = now.signed_duration_since(entry_time); let days = duration.num_days(); format!("{}d", days) } - EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), }; let title = thread_title(entry).clone(); - let full_date = - EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); + let full_date = entry_time + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); h_flex() .w_full() @@ -490,7 +600,7 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.thread_store.read(cx).is_empty(); + let has_no_history = self.is_empty(); v_flex() .key_context("ThreadHistory") @@ -623,7 +733,7 @@ impl Render for AcpThreadHistory { #[derive(IntoElement)] pub struct AcpHistoryEntryElement { - entry: DbThreadMetadata, + entry: AgentSessionInfo, thread_view: WeakEntity, selected: bool, hovered: bool, @@ -631,7 +741,7 @@ pub struct AcpHistoryEntryElement { } impl AcpHistoryEntryElement { - pub fn new(entry: DbThreadMetadata, thread_view: WeakEntity) -> Self { + pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { Self { entry, thread_view, @@ -654,24 +764,26 @@ impl AcpHistoryEntryElement { impl RenderOnce for AcpHistoryEntryElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.id.0.clone().into()); + let id = ElementId::Name(self.entry.session_id.0.clone().into()); let title = thread_title(&self.entry).clone(); - let timestamp = self.entry.updated_at; - - let formatted_time = { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }; + let formatted_time = self + .entry + .updated_at + .map(|timestamp| { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()); ListItem::new(id) .rounded() diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index d39c453df06783dd45cfd56c6c0bc980c0f0d605..1c154b3a2659561f339de751a257ad1e93b906fe 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1,11 +1,11 @@ use acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk, - AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus, ToolCall, ToolCallContent, - ToolCallStatus, UserMessageId, + AcpThread, AcpThreadEvent, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, + AgentThreadEntry, AssistantMessage, AssistantMessageChunk, AuthRequired, LoadError, MentionUri, + RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::ActionLogTelemetry; -use agent::{DbThreadMetadata, NativeAgentServer, SharedThread, ThreadStore}; +use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; use agent_servers::{AgentServer, AgentServerDelegate}; use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; @@ -310,7 +310,11 @@ pub struct AcpThreadView { project: Entity, thread_state: ThreadState, login: Option, - thread_store: Entity, + session_list: Option>, + session_list_state: Rc>>>, + recent_history_entries: Vec, + _recent_history_task: Task<()>, + _recent_history_watch_task: Option>, hovered_recent_history_item: Option, entry_view_state: Entity, message_editor: Entity, @@ -340,7 +344,7 @@ pub struct AcpThreadView { available_commands: Rc>>, is_loading_contents: bool, new_server_version_available: Option, - resume_thread_metadata: Option, + resume_thread_metadata: Option, _cancel_task: Option>, _subscriptions: [Subscription; 5], show_codex_windows_warning: bool, @@ -388,11 +392,11 @@ struct LoadingView { impl AcpThreadView { pub fn new( agent: Rc, - resume_thread: Option, - summarize_thread: Option, + resume_thread: Option, + summarize_thread: Option, workspace: WeakEntity, project: Entity, - thread_store: Entity, + thread_store: Option>, prompt_store: Option>, track_load_event: bool, window: &mut Window, @@ -400,6 +404,7 @@ impl AcpThreadView { ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); + let session_list_state = Rc::new(RefCell::new(None)); let agent_server_store = project.read(cx).agent_server_store().clone(); let agent_display_name = agent_server_store @@ -414,6 +419,7 @@ impl AcpThreadView { workspace.clone(), project.downgrade(), thread_store.clone(), + session_list_state.clone(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -439,6 +445,7 @@ impl AcpThreadView { workspace.clone(), project.downgrade(), thread_store.clone(), + session_list_state.clone(), prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), @@ -513,7 +520,11 @@ impl AcpThreadView { available_commands, editor_expanded: false, should_be_following: false, - thread_store, + session_list: None, + session_list_state, + recent_history_entries: Vec::new(), + _recent_history_task: Task::ready(()), + _recent_history_watch_task: None, hovered_recent_history_item: None, is_loading_contents: false, _subscriptions: subscriptions, @@ -548,6 +559,11 @@ impl AcpThreadView { self.available_commands.replace(vec![]); self.new_server_version_available.take(); self.message_queue.clear(); + self.session_list = None; + *self.session_list_state.borrow_mut() = None; + self.recent_history_entries.clear(); + self._recent_history_watch_task = None; + self._recent_history_task = Task::ready(()); self.turn_tokens = None; self.last_turn_tokens = None; self.turn_started_at = None; @@ -558,7 +574,7 @@ impl AcpThreadView { fn initial_state( agent: Rc, - resume_thread: Option, + resume_thread: Option, workspace: WeakEntity, project: Entity, track_load_event: bool, @@ -632,7 +648,7 @@ impl AcpThreadView { cx.update(|_, cx| { native_agent .0 - .update(cx, |agent, cx| agent.open_thread(resume.id, cx)) + .update(cx, |agent, cx| agent.open_thread(resume.session_id, cx)) }) .log_err() } else { @@ -684,13 +700,16 @@ impl AcpThreadView { AgentDiff::set_active_thread(&workspace, thread.clone(), window, cx); + let connection = thread.read(cx).connection().clone(); + let session_id = thread.read(cx).session_id().clone(); + let session_list = connection.session_list(cx); + this.set_session_list(session_list, cx); + // Check for config options first // Config options take precedence over legacy mode/model selectors // (feature flag gating happens at the data layer) - let config_options_provider = thread - .read(cx) - .connection() - .session_config_options(thread.read(cx).session_id(), cx); + let config_options_provider = + connection.session_config_options(&session_id, cx); let mode_selector; if let Some(config_options) = config_options_provider { @@ -705,11 +724,8 @@ impl AcpThreadView { } else { // Fall back to legacy mode/model selectors this.config_options_view = None; - this.model_selector = thread - .read(cx) - .connection() - .model_selector(thread.read(cx).session_id()) - .map(|selector| { + this.model_selector = + connection.model_selector(&session_id).map(|selector| { let agent_server = this.agent.clone(); let fs = this.project.read(cx).fs().clone(); cx.new(|cx| { @@ -725,22 +741,21 @@ impl AcpThreadView { }) }); - mode_selector = thread - .read(cx) - .connection() - .session_modes(thread.read(cx).session_id(), cx) - .map(|session_modes| { - let fs = this.project.read(cx).fs().clone(); - let focus_handle = this.focus_handle(cx); - cx.new(|_cx| { - ModeSelector::new( - session_modes, - this.agent.clone(), - fs, - focus_handle, - ) - }) - }); + mode_selector = + connection + .session_modes(&session_id, cx) + .map(|session_modes| { + let fs = this.project.read(cx).fs().clone(); + let focus_handle = this.focus_handle(cx); + cx.new(|_cx| { + ModeSelector::new( + session_modes, + this.agent.clone(), + fs, + focus_handle, + ) + }) + }); } let mut subscriptions = vec![ @@ -942,6 +957,10 @@ impl AcpThreadView { } } + pub(crate) fn session_list(&self) -> Option> { + self.session_list.clone() + } + pub fn mode_selector(&self) -> Option<&Entity> { match &self.thread_state { ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(), @@ -1047,9 +1066,16 @@ impl AcpThreadView { let Some(thread) = self.as_native_thread(cx) else { return; }; + let Some(session_list) = self + .session_list + .clone() + .and_then(|list| list.downcast::()) + else { + return; + }; + let thread_store = session_list.thread_store().clone(); let client = self.project.read(cx).client(); - let thread_store = self.thread_store.clone(); let session_id = thread.read(cx).id().clone(); cx.spawn_in(window, async move |this, cx| { @@ -1069,10 +1095,12 @@ impl AcpThreadView { }) .await?; - let thread_metadata = agent::DbThreadMetadata { - id: session_id, - title: format!("🔗 {}", response.title).into(), - updated_at: chrono::Utc::now(), + let thread_metadata = AgentSessionInfo { + session_id, + cwd: None, + title: Some(format!("🔗 {}", response.title).into()), + updated_at: Some(chrono::Utc::now()), + meta: None, }; this.update_in(cx, |this, window, cx| { @@ -4052,20 +4080,64 @@ impl AcpThreadView { ) } + fn set_session_list( + &mut self, + session_list: Option>, + cx: &mut Context, + ) { + if let (Some(current), Some(next)) = (&self.session_list, &session_list) + && Rc::ptr_eq(current, next) + { + return; + } + + self.session_list = session_list.clone(); + *self.session_list_state.borrow_mut() = session_list; + self.recent_history_entries.clear(); + self.hovered_recent_history_item = None; + self.refresh_recent_history(cx); + + self._recent_history_watch_task = self.session_list.as_ref().and_then(|session_list| { + let mut rx = session_list.watch(cx)?; + Some(cx.spawn(async move |this, cx| { + while let Ok(()) = rx.recv().await { + this.update(cx, |this, cx| { + this.refresh_recent_history(cx); + }) + .ok(); + } + })) + }); + } + + fn refresh_recent_history(&mut self, cx: &mut Context) { + let Some(session_list) = self.session_list.clone() else { + return; + }; + + let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); + self._recent_history_task = cx.spawn(async move |this, cx| match task.await { + Ok(response) => { + this.update(cx, |this, cx| { + this.recent_history_entries = response.sessions.into_iter().take(3).collect(); + this.hovered_recent_history_item = None; + cx.notify(); + }) + .ok(); + } + Err(error) => { + log::error!("Failed to load recent session history: {error:#}"); + } + }); + } + fn render_recent_history(&self, cx: &mut Context) -> AnyElement { - let render_history = self - .agent - .clone() - .downcast::() - .is_some() - && !self.thread_store.read(cx).is_empty(); + let render_history = self.session_list.is_some() && !self.recent_history_entries.is_empty(); v_flex() .size_full() .when(render_history, |this| { - let recent_history: Vec<_> = self.thread_store.update(cx, |thread_store, _| { - thread_store.entries().take(3).collect() - }); + let recent_history = self.recent_history_entries.clone(); this.justify_end().child( v_flex() .child( @@ -5527,10 +5599,12 @@ impl AcpThreadView { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.load_agent_thread( - DbThreadMetadata { - id, - title: name.into(), - updated_at: Default::default(), + AgentSessionInfo { + session_id: id, + cwd: None, + title: Some(name.into()), + updated_at: None, + meta: None, }, window, cx, @@ -6784,10 +6858,11 @@ impl AcpThreadView { })) } - pub fn delete_history_entry(&mut self, entry: DbThreadMetadata, cx: &mut Context) { - let task = self - .thread_store - .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); + pub fn delete_history_entry(&mut self, entry: AgentSessionInfo, cx: &mut Context) { + let Some(session_list) = self.session_list.as_ref() else { + return; + }; + let task = session_list.delete_session(&entry.session_id, cx); task.detach_and_log_err(cx); } @@ -7213,7 +7288,7 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { #[cfg(test)] pub(crate) mod tests { - use acp_thread::StubAgentConnection; + use acp_thread::{AgentSessionListResponse, StubAgentConnection}; use action_log::ActionLog; use agent_client_protocol::SessionId; use editor::MultiBufferOffset; @@ -7224,6 +7299,7 @@ pub(crate) mod tests { use settings::SettingsStore; use std::any::Any; use std::path::Path; + use std::rc::Rc; use workspace::Item; use super::*; @@ -7291,6 +7367,55 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_recent_history_refreshes_when_session_list_swapped(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let session_a = AgentSessionInfo::new(SessionId::new("session-a")); + let session_b = AgentSessionInfo::new(SessionId::new("session-b")); + + let list_a: Rc = + Rc::new(StubSessionList::new(vec![session_a.clone()])); + let list_b: Rc = + Rc::new(StubSessionList::new(vec![session_b.clone()])); + + thread_view.update(cx, |view, cx| { + view.set_session_list(Some(list_a.clone()), cx); + }); + cx.run_until_parked(); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.recent_history_entries.len(), 1); + assert_eq!( + view.recent_history_entries[0].session_id, + session_a.session_id + ); + + let session_list = view.session_list_state.borrow(); + let session_list = session_list.as_ref().expect("session list should be set"); + assert!(Rc::ptr_eq(session_list, &list_a)); + }); + + thread_view.update(cx, |view, cx| { + view.set_session_list(Some(list_b.clone()), cx); + }); + cx.run_until_parked(); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.recent_history_entries.len(), 1); + assert_eq!( + view.recent_history_entries[0].session_id, + session_b.session_id + ); + + let session_list = view.session_list_state.borrow(); + let session_list = session_list.as_ref().expect("session list should be set"); + assert!(Rc::ptr_eq(session_list, &list_b)); + }); + } + #[gpui::test] async fn test_refusal_handling(cx: &mut TestAppContext) { init_test(cx); @@ -7531,7 +7656,7 @@ pub(crate) mod tests { None, workspace.downgrade(), project, - thread_store, + Some(thread_store), None, false, window, @@ -7607,6 +7732,30 @@ pub(crate) mod tests { } } + #[derive(Clone)] + struct StubSessionList { + sessions: Vec, + } + + impl StubSessionList { + fn new(sessions: Vec) -> Self { + Self { sessions } + } + } + + impl AgentSessionList for StubSessionList { + fn list_sessions( + &self, + _request: AgentSessionListRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) + } + fn into_any(self: Rc) -> Rc { + self + } + } + impl AgentServer for StubAgentServer where C: 'static + AgentConnection + Send + Clone, @@ -7798,7 +7947,7 @@ pub(crate) mod tests { None, workspace.downgrade(), project.clone(), - thread_store.clone(), + Some(thread_store.clone()), None, false, window, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 49fd82f5b714e352f7c7465833e303042494b70f..2bb0d36170c22d38dfca130ee74f85767880ede1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,7 +1,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; -use acp_thread::AcpThread; -use agent::{ContextServerRegistry, DbThreadMetadata, ThreadStore}; +use acp_thread::{AcpThread, AgentSessionInfo}; +use agent::{ContextServerRegistry, ThreadStore}; use agent_servers::AgentServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ @@ -315,7 +315,7 @@ impl ActiveView { None, workspace, project, - thread_store, + Some(thread_store), prompt_store, false, window, @@ -429,6 +429,7 @@ pub struct AgentPanel { context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, + history_subscription: Option, active_view: ActiveView, previous_view: Option, new_thread_menu_handle: PopoverMenuHandle, @@ -543,7 +544,7 @@ impl AgentPanel { cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let acp_history = cx.new(|cx| AcpThreadHistory::new(thread_store.clone(), window, cx)); + let acp_history = cx.new(|cx| AcpThreadHistory::new(None, window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); cx.subscribe_in( @@ -683,6 +684,7 @@ impl AgentPanel { prompt_store, configuration: None, configuration_subscription: None, + history_subscription: None, context_server_registry, previous_view: None, new_thread_menu_handle: PopoverMenuHandle::default(), @@ -732,7 +734,7 @@ impl AgentPanel { pub fn open_thread( &mut self, - thread: DbThreadMetadata, + thread: AgentSessionInfo, window: &mut Window, cx: &mut Context, ) { @@ -788,9 +790,9 @@ impl AgentPanel { cx: &mut Context, ) { let Some(thread) = self - .thread_store + .acp_history .read(cx) - .thread_from_session_id(&action.from_session_id) + .session_for_id(&action.from_session_id) else { return; }; @@ -798,7 +800,7 @@ impl AgentPanel { self.external_thread( Some(ExternalAgent::NativeAgent), None, - Some(thread.clone()), + Some(thread), window, cx, ); @@ -850,8 +852,8 @@ impl AgentPanel { fn external_thread( &mut self, agent_choice: Option, - resume_thread: Option, - summarize_thread: Option, + resume_thread: Option, + summarize_thread: Option, window: &mut Window, cx: &mut Context, ) { @@ -1349,10 +1351,12 @@ impl AgentPanel { HistoryKind::AgentThreads => { let entries = panel .read(cx) - .thread_store + .acp_history .read(cx) - .entries() + .sessions() + .iter() .take(RECENTLY_UPDATED_MENU_LIMIT) + .cloned() .collect::>(); if entries.is_empty() { @@ -1362,11 +1366,12 @@ impl AgentPanel { menu = menu.header("Recently Updated"); for entry in entries { - let title = if entry.title.is_empty() { - SharedString::new_static(DEFAULT_THREAD_TITLE) - } else { - entry.title.clone() - }; + let title = entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .cloned() + .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE)); menu = menu.entry(title, None, { let panel = panel.downgrade(); @@ -1508,7 +1513,7 @@ impl AgentPanel { pub fn load_agent_thread( &mut self, - thread: DbThreadMetadata, + thread: AgentSessionInfo, window: &mut Window, cx: &mut Context, ) { @@ -1524,8 +1529,8 @@ impl AgentPanel { fn _external_thread( &mut self, server: Rc, - resume_thread: Option, - summarize_thread: Option, + resume_thread: Option, + summarize_thread: Option, workspace: WeakEntity, project: Entity, loading: bool, @@ -1538,6 +1543,11 @@ impl AgentPanel { self.selected_agent = selected_agent; self.serialize(cx); } + let thread_store = server + .clone() + .downcast::() + .is_some() + .then(|| self.thread_store.clone()); let thread_view = cx.new(|cx| { crate::acp::AcpThreadView::new( @@ -1546,7 +1556,7 @@ impl AgentPanel { summarize_thread, workspace.clone(), project, - self.thread_store.clone(), + thread_store, self.prompt_store.clone(), !loading, window, @@ -1554,6 +1564,15 @@ impl AgentPanel { ) }); + let acp_history = self.acp_history.clone(); + self.history_subscription = Some(cx.observe(&thread_view, move |_, thread_view, cx| { + if let Some(session_list) = thread_view.read(cx).session_list() { + acp_history.update(cx, |history, cx| { + history.set_session_list(Some(session_list), cx); + }); + } + })); + self.set_active_view( ActiveView::ExternalAgentThread { thread_view }, !loading, @@ -2495,7 +2514,7 @@ impl AgentPanel { false } _ => { - let history_is_empty = self.thread_store.read(cx).is_empty(); + let history_is_empty = self.acp_history.read(cx).is_empty(); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) .visible_providers() diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 5430a5972f30cfd5e5a9979b736473b7f2f1e2cf..d30d7e59b913541a0ff4746544f43c5bf90b61ec 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1,17 +1,19 @@ +use std::cell::RefCell; use std::cmp::Reverse; use std::ops::Range; use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use acp_thread::MentionUri; -use agent::{DbThreadMetadata, ThreadStore}; +use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, MentionUri}; +use agent::ThreadStore; use anyhow::Result; use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, }; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; -use gpui::{App, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; use ordered_float::OrderedFloat; @@ -132,8 +134,8 @@ impl PromptContextType { pub(crate) enum Match { File(FileMatch), Symbol(SymbolMatch), - Thread(DbThreadMetadata), - RecentThread(DbThreadMetadata), + Thread(AgentSessionInfo), + RecentThread(AgentSessionInfo), Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), @@ -158,12 +160,12 @@ pub struct EntryMatch { entry: PromptContextEntry, } -fn thread_title(thread: &DbThreadMetadata) -> SharedString { - if thread.title.is_empty() { - "New Thread".into() - } else { - thread.title.clone() - } +fn session_title(session: &AgentSessionInfo) -> SharedString { + session + .title + .clone() + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| SharedString::new_static("New Thread")) } #[derive(Debug, Clone)] @@ -194,7 +196,8 @@ pub struct PromptCompletionProvider { source: Arc, editor: WeakEntity, mention_set: Entity, - thread_store: Entity, + thread_store: Option>, + session_list: Rc>>>, prompt_store: Option>, workspace: WeakEntity, } @@ -204,7 +207,8 @@ impl PromptCompletionProvider { source: T, editor: WeakEntity, mention_set: Entity, - thread_store: Entity, + thread_store: Option>, + session_list: Rc>>>, prompt_store: Option>, workspace: WeakEntity, ) -> Self { @@ -214,6 +218,7 @@ impl PromptCompletionProvider { mention_set, workspace, thread_store, + session_list, prompt_store, } } @@ -254,7 +259,7 @@ impl PromptCompletionProvider { } fn completion_for_thread( - thread_entry: DbThreadMetadata, + thread_entry: AgentSessionInfo, source_range: Range, recent: bool, source: Arc, @@ -263,9 +268,9 @@ impl PromptCompletionProvider { workspace: Entity, cx: &mut App, ) -> Completion { - let title = thread_title(&thread_entry); + let title = session_title(&thread_entry); let uri = MentionUri::Thread { - id: thread_entry.id, + id: thread_entry.session_id, name: title.to_string(), }; @@ -648,15 +653,29 @@ impl PromptCompletionProvider { } Some(PromptContextType::Thread) => { - let search_threads_task = - search_threads(query, cancellation_flag, &self.thread_store, cx); - cx.background_spawn(async move { - search_threads_task - .await - .into_iter() - .map(Match::Thread) - .collect() - }) + if let Some(session_list) = self.session_list.borrow().clone() { + let search_sessions_task = + search_sessions(query, cancellation_flag, session_list, cx); + cx.spawn(async move |_cx| { + search_sessions_task + .await + .into_iter() + .map(Match::Thread) + .collect() + }) + } else if let Some(thread_store) = self.thread_store.as_ref() { + let search_threads_task = + search_threads(query, cancellation_flag, thread_store, cx); + cx.background_spawn(async move { + search_threads_task + .await + .into_iter() + .map(Match::Thread) + .collect() + }) + } else { + Task::ready(Vec::new()) + } } Some(PromptContextType::Fetch) => { @@ -684,20 +703,23 @@ impl PromptCompletionProvider { } None if query.is_empty() => { - let mut matches = self.recent_context_picker_entries(&workspace, cx); - - matches.extend( - self.available_context_picker_entries(&workspace, cx) - .into_iter() - .map(|mode| { - Match::Entry(EntryMatch { - entry: mode, - mat: None, - }) - }), - ); + let recent_task = self.recent_context_picker_entries(&workspace, cx); + let entries = self + .available_context_picker_entries(&workspace, cx) + .into_iter() + .map(|mode| { + Match::Entry(EntryMatch { + entry: mode, + mat: None, + }) + }) + .collect::>(); - Task::ready(matches) + cx.spawn(async move |_cx| { + let mut matches = recent_task.await; + matches.extend(entries); + matches + }) } None => { let executor = cx.background_executor().clone(); @@ -753,7 +775,7 @@ impl PromptCompletionProvider { &self, workspace: &Entity, cx: &mut App, - ) -> Vec { + ) -> Task> { let mut recent = Vec::with_capacity(6); let mut mentions = self @@ -809,26 +831,61 @@ impl PromptCompletionProvider { }), ); - if self.source.supports_context(PromptContextType::Thread, cx) { - const RECENT_COUNT: usize = 2; - let threads = self - .thread_store - .read(cx) - .entries() - .filter(|thread| { - let uri = MentionUri::Thread { - id: thread.id.clone(), - name: thread_title(thread).to_string(), - }; - !mentions.contains(&uri) - }) - .take(RECENT_COUNT) - .collect::>(); + if !self.source.supports_context(PromptContextType::Thread, cx) { + return Task::ready(recent); + } + + if let Some(session_list) = self.session_list.borrow().clone() { + let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); + return cx.spawn(async move |_cx| { + let sessions = match task.await { + Ok(response) => response.sessions, + Err(error) => { + log::error!("Failed to load recent sessions: {error:#}"); + return recent; + } + }; + + const RECENT_COUNT: usize = 2; + let threads = sessions + .into_iter() + .filter(|session| { + let uri = MentionUri::Thread { + id: session.session_id.clone(), + name: session_title(session).to_string(), + }; + !mentions.contains(&uri) + }) + .take(RECENT_COUNT) + .collect::>(); - recent.extend(threads.into_iter().map(Match::RecentThread)); + recent.extend(threads.into_iter().map(Match::RecentThread)); + recent + }); } - recent + let Some(thread_store) = self.thread_store.as_ref() else { + return Task::ready(recent); + }; + + const RECENT_COUNT: usize = 2; + let threads = thread_store + .read(cx) + .entries() + .map(thread_metadata_to_session_info) + .filter(|thread| { + let uri = MentionUri::Thread { + id: thread.session_id.clone(), + name: session_title(thread).to_string(), + }; + !mentions.contains(&uri) + }) + .take(RECENT_COUNT) + .collect::>(); + + recent.extend(threads.into_iter().map(Match::RecentThread)); + + Task::ready(recent) } fn available_context_picker_entries( @@ -1548,37 +1605,84 @@ pub(crate) fn search_threads( cancellation_flag: Arc, thread_store: &Entity, cx: &mut App, -) -> Task> { - let threads = thread_store.read(cx).entries().collect(); +) -> Task> { + let sessions = thread_store + .read(cx) + .entries() + .map(thread_metadata_to_session_info) + .collect::>(); if query.is_empty() { - return Task::ready(threads); + return Task::ready(sessions); } let executor = cx.background_executor().clone(); cx.background_spawn(async move { - let candidates = threads - .iter() - .enumerate() - .map(|(id, thread)| StringMatchCandidate::new(id, thread_title(thread).as_ref())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - 100, - &cancellation_flag, - executor, - ) - .await; + filter_sessions(query, cancellation_flag, sessions, executor).await + }) +} - matches - .into_iter() - .map(|mat| threads[mat.candidate_id].clone()) - .collect() +pub(crate) fn search_sessions( + query: String, + cancellation_flag: Arc, + session_list: Rc, + cx: &mut App, +) -> Task> { + let task = session_list.list_sessions(AgentSessionListRequest::default(), cx); + let executor = cx.background_executor().clone(); + cx.spawn(async move |_cx| { + let sessions = match task.await { + Ok(response) => response.sessions, + Err(error) => { + log::error!("Failed to list sessions: {error:#}"); + return Vec::new(); + } + }; + if query.is_empty() { + return sessions; + } + filter_sessions(query, cancellation_flag, sessions, executor).await }) } +async fn filter_sessions( + query: String, + cancellation_flag: Arc, + sessions: Vec, + executor: BackgroundExecutor, +) -> Vec { + let titles = sessions.iter().map(session_title).collect::>(); + let candidates = titles + .iter() + .enumerate() + .map(|(id, title)| StringMatchCandidate::new(id, title.as_ref())) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + 100, + &cancellation_flag, + executor, + ) + .await; + + matches + .into_iter() + .map(|mat| sessions[mat.candidate_id].clone()) + .collect() +} + +fn thread_metadata_to_session_info(entry: agent::DbThreadMetadata) -> AgentSessionInfo { + AgentSessionInfo { + session_id: entry.id, + cwd: None, + title: Some(entry.title), + updated_at: Some(entry.updated_at), + meta: None, + } +} + pub(crate) fn search_rules( query: String, cancellation_flag: Arc, @@ -1703,6 +1807,9 @@ fn selection_ranges( #[cfg(test)] mod tests { use super::*; + use acp_thread::AgentSessionListResponse; + use gpui::TestAppContext; + use std::{any::Any, rc::Rc}; #[test] fn test_prompt_completion_parse() { @@ -1929,4 +2036,49 @@ mod tests { "Should not parse with a space after @ at the start of the line" ); } + + #[gpui::test] + async fn test_search_sessions_filters_results(cx: &mut TestAppContext) { + #[derive(Clone)] + struct StubSessionList { + sessions: Vec, + } + + impl AgentSessionList for StubSessionList { + fn list_sessions( + &self, + _request: AgentSessionListRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + + let mut alpha = AgentSessionInfo::new("session-alpha"); + alpha.title = Some("Alpha Session".into()); + let mut beta = AgentSessionInfo::new("session-beta"); + beta.title = Some("Beta Session".into()); + + let session_list: Rc = Rc::new(StubSessionList { + sessions: vec![alpha.clone(), beta], + }); + + let task = { + let mut app = cx.app.borrow_mut(); + search_sessions( + "Alpha".into(), + Arc::new(AtomicBool::default()), + session_list, + &mut app, + ) + }; + + let results = task.await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].session_id, alpha.session_id); + } } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 2ae2e867c2c0685557798d457f74322cb035cf4f..a389fd132745e78540d41427e8ce9265af799e35 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -19,6 +19,7 @@ use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; use settings::Settings; +use std::cell::RefCell; use std::cmp; use std::ops::Range; use std::rc::Rc; @@ -331,7 +332,8 @@ impl PromptEditor { PromptEditorCompletionProviderDelegate, cx.weak_entity(), self.mention_set.clone(), - self.thread_store.clone(), + Some(self.thread_store.clone()), + Rc::new(RefCell::new(None)), self.prompt_store.clone(), self.workspace.clone(), )))); @@ -1249,8 +1251,8 @@ impl PromptEditor { editor }); - let mention_set = - cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); + let mention_set = cx + .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone())); let model_selector_menu_handle = PopoverMenuHandle::default(); @@ -1402,8 +1404,8 @@ impl PromptEditor { editor }); - let mention_set = - cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone())); + let mention_set = cx + .new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone())); let model_selector_menu_handle = PopoverMenuHandle::default(); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 395bbd8d7891b9e5d379c40972bbd9419305f4a1..92a9751706debc03abcb819ceabc0dcb3a780e26 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -60,7 +60,7 @@ pub struct MentionImage { pub struct MentionSet { project: WeakEntity, - thread_store: Entity, + thread_store: Option>, prompt_store: Option>, mentions: HashMap, } @@ -68,7 +68,7 @@ pub struct MentionSet { impl MentionSet { pub fn new( project: WeakEntity, - thread_store: Entity, + thread_store: Option>, prompt_store: Option>, ) -> Self { Self { @@ -138,6 +138,11 @@ impl MentionSet { self.mentions.drain() } + #[cfg(test)] + pub fn has_thread_store(&self) -> bool { + self.thread_store.is_some() + } + pub fn confirm_mention_completion( &mut self, crease_text: SharedString, @@ -473,13 +478,18 @@ impl MentionSet { id: acp::SessionId, cx: &mut Context, ) -> Task> { + let Some(thread_store) = self.thread_store.clone() else { + return Task::ready(Err(anyhow!( + "Thread mentions are only supported for the native agent" + ))); + }; let Some(project) = self.project.upgrade() else { return Task::ready(Err(anyhow!("project not found"))); }; let server = Rc::new(agent::NativeAgentServer::new( project.read(cx).fs().clone(), - self.thread_store.clone(), + thread_store, )); let delegate = AgentServerDelegate::new( project.read(cx).agent_server_store().clone(), @@ -503,6 +513,56 @@ impl MentionSet { } } +#[cfg(test)] +mod tests { + use super::*; + + use fs::FakeFs; + use gpui::TestAppContext; + use project::Project; + use prompt_store; + use release_channel; + use semver::Version; + use serde_json::json; + use settings::SettingsStore; + use std::path::Path; + use theme; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(Version::new(0, 0, 0), cx); + prompt_store::init(cx); + }); + } + + #[gpui::test] + async fn test_thread_mentions_disabled(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({"file": ""})).await; + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let thread_store = None; + let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None)); + + let task = mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx) + }); + + let error = task.await.unwrap_err(); + assert!( + error + .to_string() + .contains("Thread mentions are only supported for the native agent"), + "Unexpected error: {error:#}" + ); + } +} + pub(crate) fn paste_images_as_context( editor: Entity, mention_set: Entity, diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml index 28458a66ddc2d01f7747781b36646e125be98f8d..18e5bd06f8e49195dc84a66168f52007afb11931 100644 --- a/crates/agent_ui_v2/Cargo.toml +++ b/crates/agent_ui_v2/Cargo.toml @@ -18,6 +18,7 @@ test-support = ["agent/test-support"] [dependencies] agent.workspace = true +acp_thread.workspace = true agent-client-protocol.workspace = true agent_servers.workspace = true agent_settings.workspace = true @@ -30,6 +31,7 @@ feature_flags.workspace = true fs.workspace = true fuzzy.workspace = true gpui.workspace = true +log.workspace = true menu.workspace = true project.workspace = true prompt_store.workspace = true diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs index 1689a99711abaaa6eeca61ada35866bec9901ffa..60d8fdccaeb3db3302e912728ff1a1d3c3eb3464 100644 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -1,4 +1,5 @@ -use agent::{DbThreadMetadata, NativeAgentServer, ThreadStore}; +use acp_thread::AgentSessionInfo; +use agent::{NativeAgentServer, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; use agent_settings::AgentSettings; @@ -89,7 +90,7 @@ impl AgentThreadPane { pub fn open_thread( &mut self, - entry: DbThreadMetadata, + entry: AgentSessionInfo, fs: Arc, workspace: WeakEntity, project: Entity, @@ -98,7 +99,7 @@ impl AgentThreadPane { window: &mut Window, cx: &mut Context, ) { - let thread_id = entry.id.clone(); + let thread_id = entry.session_id.clone(); let resume_thread = Some(entry); let agent: Rc = Rc::new(NativeAgentServer::new(fs, thread_store.clone())); @@ -110,7 +111,7 @@ impl AgentThreadPane { None, workspace, project, - thread_store, + Some(thread_store), prompt_store, true, window, diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs index 32bc9e03ea5320c956a81816f5e44f0600479d4a..8043f92738316d5e7e4da41f8c6872689d2b4eeb 100644 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -1,4 +1,7 @@ -use agent::{DbThreadMetadata, ThreadStore}; +use acp_thread::AgentSessionInfo; +use agent::{NativeAgentServer, ThreadStore}; +use agent_client_protocol as acp; +use agent_servers::{AgentServer, AgentServerDelegate}; use agent_settings::AgentSettings; use anyhow::Result; use db::kvp::KEY_VALUE_STORE; @@ -61,6 +64,7 @@ pub struct AgentsPanel { prompt_store: Option>, fs: Arc, width: Option, + pending_restore: Option, pending_serialization: Task>, _subscriptions: Vec, } @@ -121,11 +125,45 @@ impl AgentsPanel { let focus_handle = cx.focus_handle(); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx.new(|cx| AcpThreadHistory::new(thread_store.clone(), window, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(None, window, cx)); + + let history_handle = history.clone(); + let connect_project = project.clone(); + let connect_thread_store = thread_store.clone(); + let connect_fs = fs.clone(); + cx.spawn(async move |_, cx| { + let connect_task = cx.update(|cx| { + let delegate = AgentServerDelegate::new( + connect_project.read(cx).agent_server_store().clone(), + connect_project.clone(), + None, + None, + ); + let server = NativeAgentServer::new(connect_fs, connect_thread_store); + server.connect(None, delegate, cx) + }); + let connection = match connect_task.await { + Ok((connection, _)) => connection, + Err(error) => { + log::error!("Failed to connect native agent for history: {error:#}"); + return; + } + }; + + cx.update(|cx| { + if let Some(session_list) = connection.session_list(cx) { + history_handle.update(cx, |history, cx| { + history.set_session_list(Some(session_list), cx); + }); + } + }); + }) + .detach(); let this = cx.weak_entity(); let subscriptions = vec![ cx.subscribe_in(&history, window, Self::handle_history_event), + cx.observe_in(&history, window, Self::handle_history_updated), cx.on_flags_ready(move |_, cx| { this.update(cx, |_, cx| { cx.notify(); @@ -144,6 +182,7 @@ impl AgentsPanel { prompt_store, fs, width: None, + pending_restore: None, pending_serialization: Task::ready(None), _subscriptions: subscriptions, } @@ -159,15 +198,9 @@ impl AgentsPanel { return; }; - let entry = self - .thread_store - .read(cx) - .entries() - .find(|e| match thread_id { - SerializedHistoryEntryId::AcpThread(id) => e.id.to_string() == *id, - }); - - if let Some(entry) = entry { + let SerializedHistoryEntryId::AcpThread(id) = thread_id; + let session_id = acp::SessionId::new(id.clone()); + if let Some(entry) = self.history.read(cx).session_for_id(&session_id) { self.open_thread( entry, serialized_pane.expanded, @@ -175,6 +208,8 @@ impl AgentsPanel { window, cx, ); + } else { + self.pending_restore = Some(serialized_pane); } } @@ -203,6 +238,15 @@ impl AgentsPanel { cx.notify(); } + fn handle_history_updated( + &mut self, + _history: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.maybe_restore_pending(window, cx); + } + fn handle_history_event( &mut self, _history: &Entity, @@ -217,15 +261,40 @@ impl AgentsPanel { } } + fn maybe_restore_pending(&mut self, window: &mut Window, cx: &mut Context) { + if self.agent_thread_pane.is_some() { + self.pending_restore = None; + return; + } + + let Some(pending) = self.pending_restore.as_ref() else { + return; + }; + let Some(thread_id) = &pending.thread_id else { + self.pending_restore = None; + return; + }; + + let SerializedHistoryEntryId::AcpThread(id) = thread_id; + let session_id = acp::SessionId::new(id.clone()); + let Some(entry) = self.history.read(cx).session_for_id(&session_id) else { + return; + }; + + let pending = self.pending_restore.take().expect("pending restore"); + self.open_thread(entry, pending.expanded, pending.width, window, cx); + } + fn open_thread( &mut self, - entry: DbThreadMetadata, + entry: AgentSessionInfo, expanded: bool, width: Option, window: &mut Window, cx: &mut Context, ) { - let entry_id = entry.id.clone(); + let entry_id = entry.session_id.clone(); + self.pending_restore = None; if let Some(existing_pane) = &self.agent_thread_pane { if existing_pane.read(cx).thread_id() == Some(entry_id) { diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs index f45fa77daa49b40475457b5bca603ae6a006db29..90f707eb56657336a76bc5552526994365af2164 100644 --- a/crates/agent_ui_v2/src/thread_history.rs +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -1,4 +1,5 @@ -use agent::{DbThreadMetadata, ThreadStore}; +use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest}; +use agent_client_protocol as acp; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; @@ -6,7 +7,7 @@ use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, Window, actions, uniform_list, }; -use std::{fmt::Display, ops::Range}; +use std::{fmt::Display, ops::Range, rc::Rc}; use text::Bias; use time::{OffsetDateTime, UtcOffset}; use ui::{ @@ -16,12 +17,12 @@ use ui::{ const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); -fn thread_title(entry: &DbThreadMetadata) -> &SharedString { - if entry.title.is_empty() { - DEFAULT_TITLE - } else { - &entry.title - } +fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) } actions!( @@ -35,7 +36,8 @@ actions!( ); pub struct AcpThreadHistory { - pub(crate) thread_store: Entity, + session_list: Option>, + sessions: Vec, scroll_handle: UniformListScrollHandle, selected_index: usize, hovered_index: Option, @@ -45,23 +47,24 @@ pub struct AcpThreadHistory { local_timezone: UtcOffset, confirming_delete_history: bool, _update_task: Task<()>, + _watch_task: Option>, _subscriptions: Vec, } enum ListItemType { BucketSeparator(TimeBucket), Entry { - entry: DbThreadMetadata, + entry: AgentSessionInfo, format: EntryTimeFormat, }, SearchResult { - entry: DbThreadMetadata, + entry: AgentSessionInfo, positions: Vec, }, } impl ListItemType { - fn history_entry(&self) -> Option<&DbThreadMetadata> { + fn history_entry(&self) -> Option<&AgentSessionInfo> { match self { ListItemType::Entry { entry, .. } => Some(entry), ListItemType::SearchResult { entry, .. } => Some(entry), @@ -72,14 +75,14 @@ impl ListItemType { #[allow(dead_code)] pub enum ThreadHistoryEvent { - Open(DbThreadMetadata), + Open(AgentSessionInfo), } impl EventEmitter for AcpThreadHistory {} impl AcpThreadHistory { pub fn new( - thread_store: Entity, + session_list: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -100,14 +103,11 @@ impl AcpThreadHistory { } }); - let thread_store_subscription = cx.observe(&thread_store, |this, _, cx| { - this.update_visible_items(true, cx); - }); - let scroll_handle = UniformListScrollHandle::default(); let mut this = Self { - thread_store, + session_list: None, + sessions: Vec::new(), scroll_handle, selected_index: 0, hovered_index: None, @@ -119,17 +119,16 @@ impl AcpThreadHistory { .unwrap(), search_query: SharedString::default(), confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription, thread_store_subscription], + _subscriptions: vec![search_editor_subscription], _update_task: Task::ready(()), + _watch_task: None, }; - this.update_visible_items(false, cx); + this.set_session_list(session_list, cx); this } fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self - .thread_store - .update(cx, |store, _| store.entries().collect()); + let entries = self.sessions.clone(); let new_list_items = if self.search_query.is_empty() { self.add_list_separators(entries, cx) } else { @@ -150,7 +149,7 @@ impl AcpThreadHistory { .position(|visible_entry| { visible_entry .history_entry() - .is_some_and(|entry| entry.id == history_entry.id) + .is_some_and(|entry| entry.session_id == history_entry.session_id) }) .unwrap_or(0) } else { @@ -165,9 +164,112 @@ impl AcpThreadHistory { }); } + pub(crate) fn set_session_list( + &mut self, + session_list: Option>, + cx: &mut Context, + ) { + if let (Some(current), Some(next)) = (&self.session_list, &session_list) + && Rc::ptr_eq(current, next) + { + return; + } + + self.session_list = session_list; + self.sessions.clear(); + self.visible_items.clear(); + self.selected_index = 0; + self.refresh_sessions(false, cx); + + self._watch_task = self.session_list.as_ref().and_then(|session_list| { + let mut rx = session_list.watch(cx)?; + Some(cx.spawn(async move |this, cx| { + while let Ok(()) = rx.recv().await { + this.update(cx, |this, cx| { + this.refresh_sessions(true, cx); + }) + .ok(); + } + })) + }); + } + + fn refresh_sessions(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let Some(session_list) = self.session_list.clone() else { + self.update_visible_items(preserve_selected_item, cx); + return; + }; + + self._update_task = cx.spawn(async move |this, cx| { + let mut cursor: Option = None; + let mut is_first_page = true; + + loop { + let request = AgentSessionListRequest { + cursor: cursor.clone(), + ..Default::default() + }; + let task = cx.update(|cx| session_list.list_sessions(request, cx)); + let response = match task.await { + Ok(response) => response, + Err(error) => { + log::error!("Failed to load session history: {error:#}"); + return; + } + }; + + let acp_thread::AgentSessionListResponse { + sessions: page_sessions, + next_cursor, + .. + } = response; + + this.update(cx, |this, cx| { + if is_first_page { + this.sessions = page_sessions; + } else { + this.sessions.extend(page_sessions); + } + this.update_visible_items(preserve_selected_item, cx); + }) + .ok(); + + is_first_page = false; + match next_cursor { + Some(next_cursor) => { + if cursor.as_ref() == Some(&next_cursor) { + log::warn!( + "Session list pagination returned the same cursor; stopping to avoid a loop." + ); + break; + } + cursor = Some(next_cursor); + } + None => break, + } + } + }); + } + + pub(crate) fn is_empty(&self) -> bool { + self.sessions.is_empty() + } + + pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option { + self.sessions + .iter() + .find(|entry| &entry.session_id == session_id) + .cloned() + } + + #[allow(dead_code)] + pub(crate) fn sessions(&self) -> &[AgentSessionInfo] { + &self.sessions + } + fn add_list_separators( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { cx.background_spawn(async move { @@ -176,8 +278,13 @@ impl AcpThreadHistory { let today = Local::now().naive_local().date(); for entry in entries.into_iter() { - let entry_date = entry.updated_at.with_timezone(&Local).naive_local().date(); - let entry_bucket = TimeBucket::from_dates(today, entry_date); + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); if Some(entry_bucket) != bucket { bucket = Some(entry_bucket); @@ -195,7 +302,7 @@ impl AcpThreadHistory { fn filter_search_results( &self, - entries: Vec, + entries: Vec, cx: &App, ) -> Task> { let query = self.search_query.clone(); @@ -236,11 +343,11 @@ impl AcpThreadHistory { self.visible_items.is_empty() && !self.search_query.is_empty() } - fn selected_history_entry(&self) -> Option<&DbThreadMetadata> { + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { self.get_history_entry(self.selected_index) } - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&DbThreadMetadata> { + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { self.visible_items.get(visible_items_ix)?.history_entry() } @@ -339,17 +446,17 @@ impl AcpThreadHistory { let Some(entry) = self.get_history_entry(visible_item_ix) else { return; }; - - let task = self - .thread_store - .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx)); + let Some(session_list) = self.session_list.as_ref() else { + return; + }; + let task = session_list.delete_session(&entry.session_id, cx); task.detach_and_log_err(cx); } fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.thread_store.update(cx, |store, cx| { - store.delete_threads(cx).detach_and_log_err(cx) - }); + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx).detach_and_log_err(cx); + } self.confirming_delete_history = false; cx.notify(); } @@ -406,7 +513,7 @@ impl AcpThreadHistory { fn render_history_entry( &self, - entry: &DbThreadMetadata, + entry: &AgentSessionInfo, format: EntryTimeFormat, ix: usize, highlight_positions: Vec, @@ -414,23 +521,27 @@ impl AcpThreadHistory { ) -> AnyElement { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; - let timestamp = entry.updated_at.timestamp(); - - let display_text = match format { - EntryTimeFormat::DateAndTime => { - let entry_time = entry.updated_at; + let display_text = match (format, entry.updated_at) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { let now = Utc::now(); let duration = now.signed_duration_since(entry_time); let days = duration.num_days(); format!("{}d", days) } - EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), }; let title = thread_title(entry).clone(); - let full_date = - EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); + let full_date = entry + .updated_at + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); h_flex() .w_full() @@ -499,7 +610,7 @@ impl Focusable for AcpThreadHistory { impl Render for AcpThreadHistory { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.thread_store.read(cx).is_empty(); + let has_no_history = self.is_empty(); v_flex() .key_context("ThreadHistory") diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index e36d3d52ddf335f1d2e5a8ae517e58cf245ebb26..6079e50b9b0254169d18a8eeddbd633f3ef64a82 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -112,7 +112,7 @@ notifications = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } prompt_store.workspace = true -recent_projects.workspace = true +recent_projects = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } remote_server.workspace = true diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 86a239cbedf3fd22868a0c0b89331e72602c1dfe..a51f5bd93f948286ee81353f92923005827d9481 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -31,7 +31,6 @@ visual-tests = [ "dep:image", "dep:semver", "dep:tempfile", - "dep:acp_thread", "dep:action_log", "dep:agent_servers", "workspace/test-support", @@ -120,7 +119,7 @@ image = { workspace = true, optional = true } semver = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } clock = { workspace = true, optional = true } -acp_thread = { workspace = true, optional = true } +acp_thread.workspace = true action_log = { workspace = true, optional = true } agent_servers = { workspace = true, optional = true } gpui_tokio.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index abe4b948ba0d266468df2b6704ea464aafbaa7e1..8edad52056600a8df51260f7660d0c708e195820 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -877,10 +877,12 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .await?; - let thread_metadata = agent::DbThreadMetadata { - id: session_id, - title: format!("🔗 {}", response.title).into(), - updated_at: chrono::Utc::now(), + let thread_metadata = acp_thread::AgentSessionInfo { + session_id, + cwd: None, + title: Some(format!("🔗 {}", response.title).into()), + updated_at: Some(chrono::Utc::now()), + meta: None, }; workspace.update(cx, |workspace, window, cx| {