mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
f651c4c340
commit
291611e3db
19 changed files with 1406 additions and 360 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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 = 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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Reference in a new issue