agent: Move thread history access behind AgentConnection trait (#46631)

This should move almost all access of the threadstore behind the trait,
which unlocks external agents supplying the list

Release Notes:

- N/A

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Ben Brandt 2026-01-12 22:42:12 +01:00 committed by GitHub
parent f651c4c340
commit 291611e3db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1406 additions and 360 deletions

3
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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>,

View file

@ -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>,

View file

@ -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(),

View file

@ -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![
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(),

View file

@ -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 = self
.entry
.updated_at
.map(|timestamp| {
let now = chrono::Utc::now();
let duration = now.signed_duration_since(timestamp);
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()
}
};
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()

View file

@ -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,

View file

@ -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()

View file

@ -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);
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<_>>();
matches.extend(
self.available_context_picker_entries(&workspace, cx)
.into_iter()
.map(|mode| {
Match::Entry(EntryMatch {
entry: mode,
mat: None,
})
}),
);
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<_>>();
recent.extend(threads.into_iter().map(Match::RecentThread));
if !self.source.supports_context(PromptContextType::Thread, cx) {
return Task::ready(recent);
}
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
});
}
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;
matches
.into_iter()
.map(|mat| threads[mat.candidate_id].clone())
.collect()
filter_sessions(query, cancellation_flag, sessions, executor).await
})
}
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);
}
}

View file

@ -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();

View file

@ -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>,

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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| {