Detailed changes
@@ -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",
@@ -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
@@ -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<Rc<dyn AgentSessionList>> {
+ None
+ }
+
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
}
@@ -153,6 +165,79 @@ pub trait AgentSessionConfigOptions {
}
}
+#[derive(Debug, Clone, Default)]
+pub struct AgentSessionListRequest {
+ pub cwd: Option<PathBuf>,
+ pub cursor: Option<String>,
+ pub meta: Option<acp::Meta>,
+}
+
+#[derive(Debug, Clone)]
+pub struct AgentSessionListResponse {
+ pub sessions: Vec<AgentSessionInfo>,
+ pub next_cursor: Option<String>,
+ pub meta: Option<acp::Meta>,
+}
+
+impl AgentSessionListResponse {
+ pub fn new(sessions: Vec<AgentSessionInfo>) -> Self {
+ Self {
+ sessions,
+ next_cursor: None,
+ meta: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct AgentSessionInfo {
+ pub session_id: acp::SessionId,
+ pub cwd: Option<PathBuf>,
+ pub title: Option<SharedString>,
+ pub updated_at: Option<DateTime<Utc>>,
+ pub meta: Option<acp::Meta>,
+}
+
+impl AgentSessionInfo {
+ pub fn new(session_id: impl Into<acp::SessionId>) -> 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<Result<AgentSessionListResponse>>;
+
+ fn delete_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn delete_sessions(&self, _cx: &mut App) -> Task<Result<()>> {
+ Task::ready(Ok(()))
+ }
+
+ fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
+ None
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
+}
+
+impl dyn AgentSessionList {
+ pub fn downcast<T: 'static + AgentSessionList + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
+ self.into_any().downcast().ok()
+ }
+}
+
#[derive(Debug)]
pub struct AuthRequired {
pub description: Option<String>,
@@ -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<Rc<dyn AgentSessionList>> {
+ let thread_store = self.0.read(cx).thread_store.clone();
+ Some(Rc::new(NativeAgentSessionList::new(thread_store, cx)) as _)
+ }
+
fn telemetry(&self) -> Option<Rc<dyn acp_thread::AgentTelemetry>> {
Some(Rc::new(self.clone()) as Rc<dyn acp_thread::AgentTelemetry>)
}
@@ -1371,6 +1379,74 @@ impl acp_thread::AgentTelemetry for NativeAgentConnection {
}
}
+pub struct NativeAgentSessionList {
+ thread_store: Entity<ThreadStore>,
+ updates_rx: watch::Receiver<()>,
+ _subscription: Subscription,
+}
+
+impl NativeAgentSessionList {
+ fn new(thread_store: Entity<ThreadStore>, 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<ThreadStore> {
+ &self.thread_store
+ }
+}
+
+impl AgentSessionList for NativeAgentSessionList {
+ fn list_sessions(
+ &self,
+ _request: AgentSessionListRequest,
+ cx: &mut App,
+ ) -> Task<Result<AgentSessionListResponse>> {
+ 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<Result<()>> {
+ self.thread_store
+ .update(cx, |store, cx| store.delete_thread(session_id.clone(), cx))
+ }
+
+ fn delete_sessions(&self, cx: &mut App) -> Task<Result<()>> {
+ self.thread_store
+ .update(cx, |store, cx| store.delete_threads(cx))
+ }
+
+ fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
+ Some(self.updates_rx.clone())
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
+
struct NativeAgentSessionTruncate {
thread: Entity<Thread>,
acp_thread: WeakEntity<AcpThread>,
@@ -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<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
+ session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
@@ -35,7 +36,8 @@ impl EntryViewState {
pub fn new(
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
+ session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -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(),
@@ -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<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
agent_name: SharedString,
+ thread_store: Option<Entity<ThreadStore>>,
_subscriptions: Vec<Subscription>,
_parse_slash_command_task: Task<()>,
}
@@ -69,11 +70,10 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
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<Workspace>,
project: WeakEntity<Project>,
- history_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
+ session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -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<Self>,
) {
+ 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", 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(),
@@ -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<ThreadStore>,
+ session_list: Option<Rc<dyn AgentSessionList>>,
+ sessions: Vec<AgentSessionInfo>,
scroll_handle: UniformListScrollHandle,
selected_index: usize,
hovered_index: Option<usize>,
@@ -37,23 +39,24 @@ pub struct AcpThreadHistory {
local_timezone: UtcOffset,
confirming_delete_history: bool,
_update_task: Task<()>,
+ _watch_task: Option<Task<()>>,
_subscriptions: Vec<gpui::Subscription>,
}
enum ListItemType {
BucketSeparator(TimeBucket),
Entry {
- entry: DbThreadMetadata,
+ entry: AgentSessionInfo,
format: EntryTimeFormat,
},
SearchResult {
- entry: DbThreadMetadata,
+ entry: AgentSessionInfo,
positions: Vec<usize>,
},
}
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<ThreadHistoryEvent> for AcpThreadHistory {}
impl AcpThreadHistory {
pub(crate) fn new(
- thread_store: Entity<ThreadStore>,
+ session_list: Option<Rc<dyn AgentSessionList>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
- 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<Rc<dyn AgentSessionList>>,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<String> = 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<AgentSessionInfo> {
+ 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<DbThreadMetadata>,
+ entries: Vec<AgentSessionInfo>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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<DbThreadMetadata>,
+ entries: Vec<AgentSessionInfo>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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>) {
- 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<usize>,
@@ -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<Self>) -> 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<AcpThreadView>,
selected: bool,
hovered: bool,
@@ -631,7 +741,7 @@ pub struct AcpHistoryEntryElement {
}
impl AcpHistoryEntryElement {
- pub fn new(entry: DbThreadMetadata, thread_view: WeakEntity<AcpThreadView>) -> Self {
+ pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<AcpThreadView>) -> 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()
@@ -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<Project>,
thread_state: ThreadState,
login: Option<task::SpawnInTerminal>,
- thread_store: Entity<ThreadStore>,
+ session_list: Option<Rc<dyn AgentSessionList>>,
+ session_list_state: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ recent_history_entries: Vec<AgentSessionInfo>,
+ _recent_history_task: Task<()>,
+ _recent_history_watch_task: Option<Task<()>>,
hovered_recent_history_item: Option<usize>,
entry_view_state: Entity<EntryViewState>,
message_editor: Entity<MessageEditor>,
@@ -340,7 +344,7 @@ pub struct AcpThreadView {
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
is_loading_contents: bool,
new_server_version_available: Option<SharedString>,
- resume_thread_metadata: Option<DbThreadMetadata>,
+ resume_thread_metadata: Option<AgentSessionInfo>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 5],
show_codex_windows_warning: bool,
@@ -388,11 +392,11 @@ struct LoadingView {
impl AcpThreadView {
pub fn new(
agent: Rc<dyn AgentServer>,
- resume_thread: Option<DbThreadMetadata>,
- summarize_thread: Option<DbThreadMetadata>,
+ resume_thread: Option<AgentSessionInfo>,
+ summarize_thread: Option<AgentSessionInfo>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
- thread_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
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<dyn AgentServer>,
- resume_thread: Option<DbThreadMetadata>,
+ resume_thread: Option<AgentSessionInfo>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
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<Rc<dyn AgentSessionList>> {
+ self.session_list.clone()
+ }
+
pub fn mode_selector(&self) -> Option<&Entity<ModeSelector>> {
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::<NativeAgentSessionList>())
+ 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<Rc<dyn AgentSessionList>>,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) -> AnyElement {
- let render_history = self
- .agent
- .clone()
- .downcast::<agent::NativeAgentServer>()
- .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::<AgentPanel>(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<Self>) {
- 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<Self>) {
+ 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<dyn AgentSessionList> =
+ Rc::new(StubSessionList::new(vec![session_a.clone()]));
+ let list_b: Rc<dyn AgentSessionList> =
+ 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<AgentSessionInfo>,
+ }
+
+ impl StubSessionList {
+ fn new(sessions: Vec<AgentSessionInfo>) -> Self {
+ Self { sessions }
+ }
+ }
+
+ impl AgentSessionList for StubSessionList {
+ fn list_sessions(
+ &self,
+ _request: AgentSessionListRequest,
+ _cx: &mut App,
+ ) -> Task<anyhow::Result<AgentSessionListResponse>> {
+ Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
+ }
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+ }
+
impl<C> AgentServer for StubAgentServer<C>
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,
@@ -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<ContextServerRegistry>,
configuration: Option<Entity<AgentConfiguration>>,
configuration_subscription: Option<Subscription>,
+ history_subscription: Option<Subscription>,
active_view: ActiveView,
previous_view: Option<ActiveView>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -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<Self>,
) {
@@ -788,9 +790,9 @@ impl AgentPanel {
cx: &mut Context<Self>,
) {
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<crate::ExternalAgent>,
- resume_thread: Option<DbThreadMetadata>,
- summarize_thread: Option<DbThreadMetadata>,
+ resume_thread: Option<AgentSessionInfo>,
+ summarize_thread: Option<AgentSessionInfo>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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::<Vec<_>>();
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<Self>,
) {
@@ -1524,8 +1529,8 @@ impl AgentPanel {
fn _external_thread(
&mut self,
server: Rc<dyn AgentServer>,
- resume_thread: Option<DbThreadMetadata>,
- summarize_thread: Option<DbThreadMetadata>,
+ resume_thread: Option<AgentSessionInfo>,
+ summarize_thread: Option<AgentSessionInfo>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
loading: bool,
@@ -1538,6 +1543,11 @@ impl AgentPanel {
self.selected_agent = selected_agent;
self.serialize(cx);
}
+ let thread_store = server
+ .clone()
+ .downcast::<agent::NativeAgentServer>()
+ .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()
@@ -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<T: PromptCompletionProviderDelegate> {
source: Arc<T>,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- thread_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
+ session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
}
@@ -204,7 +207,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
source: T,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- thread_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
+ session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
) -> Self {
@@ -214,6 +218,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
mention_set,
workspace,
thread_store,
+ session_list,
prompt_store,
}
}
@@ -254,7 +259,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
fn completion_for_thread(
- thread_entry: DbThreadMetadata,
+ thread_entry: AgentSessionInfo,
source_range: Range<Anchor>,
recent: bool,
source: Arc<T>,
@@ -263,9 +268,9 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
workspace: Entity<Workspace>,
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
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::<Vec<_>>();
- 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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
&self,
workspace: &Entity<Workspace>,
cx: &mut App,
- ) -> Vec<Match> {
+ ) -> Task<Vec<Match>> {
let mut recent = Vec::with_capacity(6);
let mut mentions = self
@@ -809,26 +831,61 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}),
);
- 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::<Vec<_>>();
+ 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::<Vec<_>>();
- 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::<Vec<_>>();
+
+ 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<AtomicBool>,
thread_store: &Entity<ThreadStore>,
cx: &mut App,
-) -> Task<Vec<DbThreadMetadata>> {
- let threads = thread_store.read(cx).entries().collect();
+) -> Task<Vec<AgentSessionInfo>> {
+ let sessions = thread_store
+ .read(cx)
+ .entries()
+ .map(thread_metadata_to_session_info)
+ .collect::<Vec<_>>();
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::<Vec<_>>();
- 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<AtomicBool>,
+ session_list: Rc<dyn AgentSessionList>,
+ cx: &mut App,
+) -> Task<Vec<AgentSessionInfo>> {
+ 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<AtomicBool>,
+ sessions: Vec<AgentSessionInfo>,
+ executor: BackgroundExecutor,
+) -> Vec<AgentSessionInfo> {
+ let titles = sessions.iter().map(session_title).collect::<Vec<_>>();
+ let candidates = titles
+ .iter()
+ .enumerate()
+ .map(|(id, title)| StringMatchCandidate::new(id, title.as_ref()))
+ .collect::<Vec<_>>();
+ 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<AtomicBool>,
@@ -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<AgentSessionInfo>,
+ }
+
+ impl AgentSessionList for StubSessionList {
+ fn list_sessions(
+ &self,
+ _request: AgentSessionListRequest,
+ _cx: &mut App,
+ ) -> Task<anyhow::Result<AgentSessionListResponse>> {
+ Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone())))
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ 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<dyn AgentSessionList> = 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);
+ }
}
@@ -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<T: 'static> PromptEditor<T> {
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<BufferCodegen> {
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<TerminalCodegen> {
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();
@@ -60,7 +60,7 @@ pub struct MentionImage {
pub struct MentionSet {
project: WeakEntity<Project>,
- thread_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
}
@@ -68,7 +68,7 @@ pub struct MentionSet {
impl MentionSet {
pub fn new(
project: WeakEntity<Project>,
- thread_store: Entity<ThreadStore>,
+ thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
) -> 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<Self>,
) -> Task<Result<Mention>> {
+ 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<Editor>,
mention_set: Entity<MentionSet>,
@@ -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
@@ -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<dyn Fs>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
@@ -98,7 +99,7 @@ impl AgentThreadPane {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let thread_id = entry.id.clone();
+ let thread_id = entry.session_id.clone();
let resume_thread = Some(entry);
let agent: Rc<dyn AgentServer> = 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,
@@ -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<Entity<PromptStore>>,
fs: Arc<dyn Fs>,
width: Option<Pixels>,
+ pending_restore: Option<SerializedAgentThreadPane>,
pending_serialization: Task<Option<()>>,
_subscriptions: Vec<Subscription>,
}
@@ -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<AcpThreadHistory>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.maybe_restore_pending(window, cx);
+ }
+
fn handle_history_event(
&mut self,
_history: &Entity<AcpThreadHistory>,
@@ -217,15 +261,40 @@ impl AgentsPanel {
}
}
+ fn maybe_restore_pending(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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) {
@@ -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<ThreadStore>,
+ session_list: Option<Rc<dyn AgentSessionList>>,
+ sessions: Vec<AgentSessionInfo>,
scroll_handle: UniformListScrollHandle,
selected_index: usize,
hovered_index: Option<usize>,
@@ -45,23 +47,24 @@ pub struct AcpThreadHistory {
local_timezone: UtcOffset,
confirming_delete_history: bool,
_update_task: Task<()>,
+ _watch_task: Option<Task<()>>,
_subscriptions: Vec<gpui::Subscription>,
}
enum ListItemType {
BucketSeparator(TimeBucket),
Entry {
- entry: DbThreadMetadata,
+ entry: AgentSessionInfo,
format: EntryTimeFormat,
},
SearchResult {
- entry: DbThreadMetadata,
+ entry: AgentSessionInfo,
positions: Vec<usize>,
},
}
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<ThreadHistoryEvent> for AcpThreadHistory {}
impl AcpThreadHistory {
pub fn new(
- thread_store: Entity<ThreadStore>,
+ session_list: Option<Rc<dyn AgentSessionList>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
- 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<Rc<dyn AgentSessionList>>,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Self>) {
+ 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<String> = 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<AgentSessionInfo> {
+ 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<DbThreadMetadata>,
+ entries: Vec<AgentSessionInfo>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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<DbThreadMetadata>,
+ entries: Vec<AgentSessionInfo>,
cx: &App,
) -> Task<Vec<ListItemType>> {
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>) {
- 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<usize>,
@@ -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<Self>) -> 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")
@@ -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
@@ -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
@@ -877,10 +877,12 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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| {