Detailed changes
@@ -435,23 +435,16 @@ dependencies = [
"agent_settings",
"agent_ui",
"anyhow",
- "chrono",
"db",
- "editor",
"feature_flags",
"fs",
- "fuzzy",
"gpui",
"log",
- "menu",
"project",
"prompt_store",
"serde",
"serde_json",
"settings",
- "text",
- "time",
- "time_format",
"ui",
"util",
"workspace",
@@ -1,6 +1,7 @@
use std::{cell::RefCell, ops::Range, rc::Rc};
-use acp_thread::{AcpThread, AgentSessionList, AgentThreadEntry};
+use super::thread_history::AcpThreadHistory;
+use acp_thread::{AcpThread, AgentThreadEntry};
use agent::ThreadStore;
use agent_client_protocol::{self as acp, ToolCallId};
use collections::HashMap;
@@ -24,7 +25,7 @@ pub struct EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
entries: Vec<Entry>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
@@ -37,7 +38,7 @@ impl EntryViewState {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -47,7 +48,7 @@ impl EntryViewState {
workspace,
project,
thread_store,
- session_list,
+ history,
prompt_store,
entries: Vec::new(),
prompt_capabilities,
@@ -89,7 +90,7 @@ impl EntryViewState {
self.workspace.clone(),
self.project.clone(),
self.thread_store.clone(),
- self.session_list.clone(),
+ self.history.clone(),
self.prompt_store.clone(),
self.prompt_capabilities.clone(),
self.available_commands.clone(),
@@ -400,7 +401,8 @@ fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
#[cfg(test)]
mod tests {
- use std::{cell::RefCell, path::Path, rc::Rc};
+ use std::path::Path;
+ use std::rc::Rc;
use acp_thread::{AgentConnection, StubAgentConnection};
use agent_client_protocol as acp;
@@ -455,14 +457,15 @@ mod tests {
});
let thread_store = None;
- let session_list = Rc::new(RefCell::new(None));
+ let history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let view_state = cx.new(|_cx| {
EntryViewState::new(
workspace.downgrade(),
project.downgrade(),
thread_store,
- session_list,
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -474,7 +477,7 @@ mod tests {
view_state.sync_entry(0, &thread, window, cx)
});
- let diff = thread.read_with(cx, |thread, _cx| {
+ let diff = thread.read_with(cx, |thread, _| {
thread
.entries()
.get(0)
@@ -1,4 +1,5 @@
use crate::QueueMessage;
+use crate::acp::AcpThreadHistory;
use crate::{
ChatWithFollow,
completion_provider::{
@@ -9,7 +10,7 @@ use crate::{
Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
},
};
-use acp_thread::{AgentSessionInfo, AgentSessionList, MentionUri};
+use acp_thread::{AgentSessionInfo, MentionUri};
use agent::ThreadStore;
use agent_client_protocol as acp;
use anyhow::{Result, anyhow};
@@ -101,7 +102,7 @@ impl MessageEditor {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<Entity<ThreadStore>>,
- session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -158,8 +159,7 @@ impl MessageEditor {
cx.entity(),
editor.downgrade(),
mention_set.clone(),
- thread_store.clone(),
- session_list,
+ history,
prompt_store.clone(),
workspace.clone(),
));
@@ -1108,7 +1108,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -1116,7 +1117,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -1214,13 +1215,14 @@ mod tests {
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
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![]));
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
@@ -1228,7 +1230,7 @@ mod tests {
workspace_handle.clone(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
@@ -1372,7 +1374,8 @@ mod tests {
let mut cx = VisualTestContext::from_window(*window, cx);
let thread_store = None;
- let session_list = Rc::new(RefCell::new(None));
+ let history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@@ -1390,7 +1393,7 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
prompt_capabilities.clone(),
available_commands.clone(),
@@ -1603,7 +1606,8 @@ mod tests {
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let session_list = Rc::new(RefCell::new(None));
+ let history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -1613,7 +1617,7 @@ mod tests {
workspace_handle,
project.downgrade(),
Some(thread_store),
- session_list.clone(),
+ history.downgrade(),
None,
prompt_capabilities.clone(),
Default::default(),
@@ -2097,7 +2101,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2105,7 +2110,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2196,7 +2201,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
// Create a thread metadata to insert as summary
let thread_metadata = AgentSessionInfo {
@@ -2213,7 +2219,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2276,7 +2282,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let thread_metadata = AgentSessionInfo {
session_id: acp::SessionId::new("thread-123"),
@@ -2292,7 +2299,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2334,7 +2341,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2342,7 +2350,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2387,7 +2395,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2395,7 +2404,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2441,7 +2450,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2449,7 +2459,7 @@ mod tests {
workspace.downgrade(),
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2504,7 +2514,8 @@ mod tests {
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 history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -2513,7 +2524,7 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -2660,7 +2671,8 @@ mod tests {
});
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let session_list = Rc::new(RefCell::new(None));
+ let history = cx
+ .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
// Create a new `MessageEditor`. The `EditorMode::full()` has to be used
// to ensure we have a fixed viewport, so we can eventually actually
@@ -2672,7 +2684,7 @@ mod tests {
workspace_handle,
project.downgrade(),
thread_store.clone(),
- session_list.clone(),
+ history.downgrade(),
None,
Default::default(),
Default::default(),
@@ -72,7 +72,7 @@ pub enum ThreadHistoryEvent {
impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
impl AcpThreadHistory {
- pub(crate) fn new(
+ pub fn new(
session_list: Option<Rc<dyn AgentSessionList>>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -155,7 +155,7 @@ impl AcpThreadHistory {
});
}
- pub(crate) fn set_session_list(
+ pub fn set_session_list(
&mut self,
session_list: Option<Rc<dyn AgentSessionList>>,
cx: &mut Context<Self>,
@@ -246,7 +246,7 @@ impl AcpThreadHistory {
self.sessions.is_empty()
}
- pub(crate) fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
+ pub fn session_for_id(&self, session_id: &acp::SessionId) -> Option<AgentSessionInfo> {
self.sessions
.iter()
.find(|entry| &entry.session_id == session_id)
@@ -257,6 +257,22 @@ impl AcpThreadHistory {
&self.sessions
}
+ pub(crate) fn get_recent_sessions(&self, limit: usize) -> Vec<AgentSessionInfo> {
+ self.sessions.iter().take(limit).cloned().collect()
+ }
+
+ pub(crate) fn delete_session(
+ &self,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<anyhow::Result<()>> {
+ if let Some(session_list) = self.session_list.as_ref() {
+ session_list.delete_session(session_id, cx)
+ } else {
+ Task::ready(Ok(()))
+ }
+ }
+
fn add_list_separators(
&self,
entries: Vec<AgentSessionInfo>,
@@ -1,7 +1,7 @@
use acp_thread::{
- AcpThread, AcpThreadEvent, AgentSessionInfo, AgentSessionList, AgentSessionListRequest,
- AgentThreadEntry, AssistantMessage, AssistantMessageChunk, AuthRequired, LoadError, MentionUri,
- RetryStatus, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
+ AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
+ AssistantMessageChunk, AuthRequired, LoadError, MentionUri, RetryStatus, ThreadStatus,
+ ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
};
use acp_thread::{AgentConnection, Plan};
use action_log::{ActionLog, ActionLogTelemetry};
@@ -61,6 +61,7 @@ use zed_actions::assistant::OpenRulesLibrary;
use super::config_options::ConfigOptionsView;
use super::entry_view_state::EntryViewState;
+use super::thread_history::AcpThreadHistory;
use crate::acp::AcpModelSelectorPopover;
use crate::acp::ModeSelector;
use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
@@ -311,11 +312,9 @@ pub struct AcpThreadView {
project: Entity<Project>,
thread_state: ThreadState,
login: Option<task::SpawnInTerminal>,
- 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<()>>,
+ history: Entity<AcpThreadHistory>,
+ _history_subscription: Subscription,
hovered_recent_history_item: Option<usize>,
entry_view_state: Entity<EntryViewState>,
message_editor: Entity<MessageEditor>,
@@ -400,13 +399,13 @@ impl AcpThreadView {
project: Entity<Project>,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
+ history: Entity<AcpThreadHistory>,
track_load_event: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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
@@ -421,7 +420,7 @@ impl AcpThreadView {
workspace.clone(),
project.downgrade(),
thread_store.clone(),
- session_list_state.clone(),
+ history.downgrade(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
@@ -447,7 +446,7 @@ impl AcpThreadView {
workspace.clone(),
project.downgrade(),
thread_store.clone(),
- session_list_state.clone(),
+ history.downgrade(),
prompt_store.clone(),
prompt_capabilities.clone(),
available_commands.clone(),
@@ -482,6 +481,11 @@ impl AcpThreadView {
&& project.read(cx).is_local()
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
+ let recent_history_entries = history.read(cx).get_recent_sessions(3);
+ let history_subscription = cx.observe(&history, |this, history, cx| {
+ this.update_recent_history_from_cache(&history, cx);
+ });
+
Self {
agent: agent.clone(),
agent_server_store,
@@ -523,11 +527,9 @@ impl AcpThreadView {
available_commands,
editor_expanded: false,
should_be_following: false,
- session_list: None,
- session_list_state,
- recent_history_entries: Vec::new(),
- _recent_history_task: Task::ready(()),
- _recent_history_watch_task: None,
+ recent_history_entries,
+ history,
+ _history_subscription: history_subscription,
hovered_recent_history_item: None,
is_loading_contents: false,
_subscriptions: subscriptions,
@@ -562,11 +564,7 @@ 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;
@@ -706,7 +704,9 @@ impl AcpThreadView {
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);
+ this.history.update(cx, |history, cx| {
+ history.set_session_list(session_list, cx);
+ });
// Check for config options first
// Config options take precedence over legacy mode/model selectors
@@ -960,10 +960,6 @@ 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(),
@@ -1066,12 +1062,13 @@ impl AcpThreadView {
return;
}
- let Some(thread) = self.as_native_thread(cx) else {
+ let Some(thread) = self.thread() else {
return;
};
+
let Some(session_list) = self
- .session_list
- .clone()
+ .as_native_connection(cx)
+ .and_then(|connection| connection.session_list(cx))
.and_then(|list| list.downcast::<NativeAgentSessionList>())
else {
return;
@@ -1079,7 +1076,7 @@ impl AcpThreadView {
let thread_store = session_list.thread_store().clone();
let client = self.project.read(cx).client();
- let session_id = thread.read(cx).id().clone();
+ let session_id = thread.read(cx).session_id().clone();
cx.spawn_in(window, async move |this, cx| {
let response = client
@@ -4203,59 +4200,18 @@ impl AcpThreadView {
)
}
- fn set_session_list(
+ fn update_recent_history_from_cache(
&mut self,
- session_list: Option<Rc<dyn AgentSessionList>>,
+ history: &Entity<AcpThreadHistory>,
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.recent_history_entries = history.read(cx).get_recent_sessions(3);
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:#}");
- }
- });
+ cx.notify();
}
fn render_recent_history(&self, cx: &mut Context<Self>) -> AnyElement {
- let render_history = self.session_list.is_some() && !self.recent_history_entries.is_empty();
+ let render_history = !self.recent_history_entries.is_empty();
v_flex()
.size_full()
@@ -7149,10 +7105,9 @@ impl AcpThreadView {
}
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);
+ let task = self.history.update(cx, |history, cx| {
+ history.delete_session(&entry.session_id, cx)
+ });
task.detach_and_log_err(cx);
}
@@ -7578,7 +7533,9 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
#[cfg(test)]
pub(crate) mod tests {
- use acp_thread::{AgentSessionListResponse, StubAgentConnection};
+ use acp_thread::{
+ AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, StubAgentConnection,
+ };
use action_log::ActionLog;
use agent_client_protocol::SessionId;
use editor::MultiBufferOffset;
@@ -7658,21 +7615,52 @@ pub(crate) mod tests {
}
#[gpui::test]
- async fn test_recent_history_refreshes_when_session_list_swapped(cx: &mut TestAppContext) {
+ async fn test_recent_history_refreshes_when_history_cache_updated(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 fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+ let (workspace, cx) =
+ cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+ let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ // Create history without an initial session list - it will be set after connection
+ let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
+
+ let thread_view = cx.update(|window, cx| {
+ cx.new(|cx| {
+ AcpThreadView::new(
+ Rc::new(StubAgentServer::default_response()),
+ None,
+ None,
+ workspace.downgrade(),
+ project,
+ Some(thread_store),
+ None,
+ history.clone(),
+ false,
+ window,
+ cx,
+ )
+ })
+ });
+
+ // Wait for connection to establish
+ cx.run_until_parked();
+
+ // Initially empty because StubAgentConnection.session_list() returns None
+ thread_view.read_with(cx, |view, _cx| {
+ assert_eq!(view.recent_history_entries.len(), 0);
+ });
+
+ // Now set the session list - this simulates external agents providing their history
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);
+ history.update(cx, |history, cx| {
+ history.set_session_list(Some(list_a), cx);
});
cx.run_until_parked();
@@ -7682,14 +7670,13 @@ pub(crate) mod tests {
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);
+ // Update to a different session list
+ let list_b: Rc<dyn AgentSessionList> =
+ Rc::new(StubSessionList::new(vec![session_b.clone()]));
+ history.update(cx, |history, cx| {
+ history.set_session_list(Some(list_b), cx);
});
cx.run_until_parked();
@@ -7699,10 +7686,6 @@ pub(crate) mod tests {
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));
});
}
@@ -7937,6 +7920,7 @@ pub(crate) mod tests {
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
@@ -7948,6 +7932,7 @@ pub(crate) mod tests {
project,
Some(thread_store),
None,
+ history,
false,
window,
cx,
@@ -8227,6 +8212,7 @@ pub(crate) mod tests {
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+ let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
let connection = Rc::new(StubAgentConnection::new());
let thread_view = cx.update(|window, cx| {
@@ -8239,6 +8225,7 @@ pub(crate) mod tests {
project.clone(),
Some(thread_store.clone()),
None,
+ history,
false,
window,
cx,
@@ -305,6 +305,7 @@ impl ActiveView {
thread_store: Entity<ThreadStore>,
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
+ history: Entity<AcpThreadHistory>,
window: &mut Window,
cx: &mut App,
) -> Self {
@@ -317,6 +318,7 @@ impl ActiveView {
project,
Some(thread_store),
prompt_store,
+ history,
false,
window,
cx,
@@ -429,7 +431,6 @@ 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>,
@@ -583,6 +584,7 @@ impl AgentPanel {
thread_store.clone(),
project.clone(),
workspace.clone(),
+ acp_history.clone(),
window,
cx,
),
@@ -684,7 +686,6 @@ 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,6 +733,10 @@ impl AgentPanel {
&self.thread_store
}
+ pub fn history(&self) -> &Entity<AcpThreadHistory> {
+ &self.acp_history
+ }
+
pub fn open_thread(
&mut self,
thread: AgentSessionInfo,
@@ -1558,21 +1563,13 @@ impl AgentPanel {
project,
thread_store,
self.prompt_store.clone(),
+ self.acp_history.clone(),
!loading,
window,
cx,
)
});
- 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,
@@ -2947,13 +2944,16 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist {
return;
};
let project = workspace.read(cx).project().downgrade();
- let thread_store = panel.read(cx).thread_store().clone();
+ let panel = panel.read(cx);
+ let thread_store = panel.thread_store().clone();
+ let history = panel.history().downgrade();
assistant.assist(
prompt_editor,
self.workspace.clone(),
project,
thread_store,
None,
+ history,
initial_prompt,
window,
cx,
@@ -1,13 +1,11 @@
-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::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, MentionUri};
-use agent::ThreadStore;
+use crate::acp::AcpThreadHistory;
+use acp_thread::{AgentSessionInfo, MentionUri};
use anyhow::Result;
use editor::{
CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
@@ -196,8 +194,7 @@ pub struct PromptCompletionProvider<T: PromptCompletionProviderDelegate> {
source: Arc<T>,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- thread_store: Option<Entity<ThreadStore>>,
- session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
}
@@ -207,8 +204,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
source: T,
editor: WeakEntity<Editor>,
mention_set: Entity<MentionSet>,
- thread_store: Option<Entity<ThreadStore>>,
- session_list: Rc<RefCell<Option<Rc<dyn AgentSessionList>>>>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
) -> Self {
@@ -217,8 +213,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
editor,
mention_set,
workspace,
- thread_store,
- session_list,
+ history,
prompt_store,
}
}
@@ -653,25 +648,12 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
Some(PromptContextType::Thread) => {
- if let Some(session_list) = self.session_list.borrow().clone() {
- let search_sessions_task =
- search_sessions(query, cancellation_flag, session_list, cx);
+ if let Some(history) = self.history.upgrade() {
+ let sessions = history.read(cx).sessions().to_vec();
+ let search_task =
+ filter_sessions_by_query(query, cancellation_flag, sessions, 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()
+ search_task.await.into_iter().map(Match::Thread).collect()
})
} else {
Task::ready(Vec::new())
@@ -835,19 +817,12 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
return Task::ready(recent);
}
- if let Some(session_list) = self.session_list.borrow().clone() {
- let task = session_list.list_sessions(AgentSessionListRequest::default(), cx);
- return cx.spawn(async move |_cx| {
- let sessions = match task.await {
- Ok(response) => response.sessions,
- Err(error) => {
- log::error!("Failed to load recent sessions: {error:#}");
- return recent;
- }
- };
-
- const RECENT_COUNT: usize = 2;
- let threads = sessions
+ if let Some(history) = self.history.upgrade() {
+ const RECENT_COUNT: usize = 2;
+ recent.extend(
+ history
+ .read(cx)
+ .sessions()
.into_iter()
.filter(|session| {
let uri = MentionUri::Thread {
@@ -857,33 +832,11 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
!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 {
+ .cloned()
+ .map(Match::RecentThread),
+ );
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)
}
@@ -1608,50 +1561,21 @@ pub(crate) fn search_symbols(
})
}
-pub(crate) fn search_threads(
+fn filter_sessions_by_query(
query: String,
cancellation_flag: Arc<AtomicBool>,
- thread_store: &Entity<ThreadStore>,
+ sessions: Vec<AgentSessionInfo>,
cx: &mut App,
) -> 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(sessions);
}
-
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
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>,
@@ -1681,16 +1605,6 @@ async fn filter_sessions(
.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>,
@@ -1815,9 +1729,7 @@ 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() {
@@ -2059,41 +1971,20 @@ mod tests {
}
#[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
- }
- }
-
+ async fn test_filter_sessions_by_query(cx: &mut TestAppContext) {
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 sessions = vec![alpha.clone(), beta];
let task = {
let mut app = cx.app.borrow_mut();
- search_sessions(
+ filter_sessions_by_query(
"Alpha".into(),
Arc::new(AtomicBool::default()),
- session_list,
+ sessions,
&mut app,
)
};
@@ -7,6 +7,7 @@ use std::rc::Rc;
use std::sync::Arc;
use uuid::Uuid;
+use crate::acp::AcpThreadHistory;
use crate::context::load_context;
use crate::mention_set::MentionSet;
use crate::{
@@ -264,6 +265,7 @@ impl InlineAssistant {
let prompt_store = agent_panel.prompt_store().as_ref().cloned();
let thread_store = agent_panel.thread_store().clone();
+ let history = agent_panel.history().downgrade();
let handle_assist =
|window: &mut Window, cx: &mut Context<Workspace>| match inline_assist_target {
@@ -275,6 +277,7 @@ impl InlineAssistant {
workspace.project().downgrade(),
thread_store,
prompt_store,
+ history,
action.prompt.clone(),
window,
cx,
@@ -289,6 +292,7 @@ impl InlineAssistant {
workspace.project().downgrade(),
thread_store,
prompt_store,
+ history,
action.prompt.clone(),
window,
cx,
@@ -470,6 +474,7 @@ impl InlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
initial_prompt: Option<String>,
window: &mut Window,
codegen_ranges: &[Range<Anchor>],
@@ -516,6 +521,7 @@ impl InlineAssistant {
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
+ history.clone(),
project.clone(),
workspace.clone(),
window,
@@ -607,6 +613,7 @@ impl InlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@@ -625,6 +632,7 @@ impl InlineAssistant {
project,
thread_store,
prompt_store,
+ history,
initial_prompt,
window,
&codegen_ranges,
@@ -650,6 +658,7 @@ impl InlineAssistant {
workspace: Entity<Workspace>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
window: &mut Window,
cx: &mut App,
) -> InlineAssistId {
@@ -669,6 +678,7 @@ impl InlineAssistant {
project,
thread_store,
prompt_store,
+ history,
Some(initial_prompt),
window,
&[range],
@@ -1937,16 +1947,13 @@ impl CodeActionProvider for AssistantCodeActionProvider {
let prompt_store = PromptStore::global(cx);
window.spawn(cx, async move |cx| {
let workspace = workspace.upgrade().context("workspace was released")?;
- let thread_store = cx.update(|_window, cx| {
- anyhow::Ok(
- workspace
- .read(cx)
- .panel::<AgentPanel>(cx)
- .context("missing agent panel")?
- .read(cx)
- .thread_store()
- .clone(),
- )
+ let (thread_store, history) = cx.update(|_window, cx| {
+ let panel = workspace
+ .read(cx)
+ .panel::<AgentPanel>(cx)
+ .context("missing agent panel")?
+ .read(cx);
+ anyhow::Ok((panel.thread_store().clone(), panel.history().downgrade()))
})??;
let editor = editor.upgrade().context("editor was released")?;
let range = editor
@@ -1992,6 +1999,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
workspace,
thread_store,
prompt_store,
+ history,
window,
cx,
);
@@ -2114,7 +2122,7 @@ pub mod test {
setup(cx);
- let (_editor, buffer) = cx.update(|window, cx| {
+ let (_editor, buffer, _history) = cx.update(|window, cx| {
let buffer = cx.new(|cx| Buffer::local("", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx));
@@ -2131,6 +2139,7 @@ pub mod test {
});
let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let history = cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx));
// Add editor to workspace
workspace.update(cx, |workspace, cx| {
@@ -2146,6 +2155,7 @@ pub mod test {
project.downgrade(),
thread_store,
None,
+ history.downgrade(),
Some(prompt),
window,
cx,
@@ -2155,7 +2165,7 @@ pub mod test {
inline_assistant.start_assist(assist_id, window, cx);
});
- (editor, buffer)
+ (editor, buffer, history)
});
cx.run_until_parked();
@@ -1,3 +1,4 @@
+use crate::acp::AcpThreadHistory;
use agent::ThreadStore;
use collections::{HashMap, VecDeque};
use editor::actions::Paste;
@@ -19,7 +20,6 @@ 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;
@@ -61,7 +61,7 @@ pub struct PromptEditor<T> {
pub editor: Entity<Editor>,
mode: PromptEditorMode,
mention_set: Entity<MentionSet>,
- thread_store: Entity<ThreadStore>,
+ history: WeakEntity<AcpThreadHistory>,
prompt_store: Option<Entity<PromptStore>>,
workspace: WeakEntity<Workspace>,
model_selector: Entity<AgentModelSelector>,
@@ -332,8 +332,7 @@ impl<T: 'static> PromptEditor<T> {
PromptEditorCompletionProviderDelegate,
cx.weak_entity(),
self.mention_set.clone(),
- Some(self.thread_store.clone()),
- Rc::new(RefCell::new(None)),
+ self.history.clone(),
self.prompt_store.clone(),
self.workspace.clone(),
))));
@@ -1213,6 +1212,7 @@ impl PromptEditor<BufferCodegen> {
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@@ -1259,7 +1259,7 @@ impl PromptEditor<BufferCodegen> {
let mut this: PromptEditor<BufferCodegen> = PromptEditor {
editor: prompt_editor.clone(),
mention_set,
- thread_store,
+ history,
prompt_store,
workspace,
model_selector: cx.new(|cx| {
@@ -1371,6 +1371,7 @@ impl PromptEditor<TerminalCodegen> {
fs: Arc<dyn Fs>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
project: WeakEntity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
@@ -1412,7 +1413,7 @@ impl PromptEditor<TerminalCodegen> {
let mut this = Self {
editor: prompt_editor.clone(),
mention_set,
- thread_store,
+ history,
prompt_store,
workspace,
model_selector: cx.new(|cx| {
@@ -1,4 +1,5 @@
use crate::{
+ acp::AcpThreadHistory,
context::load_context,
inline_prompt_editor::{
CodegenStatus, PromptEditor, PromptEditorEvent, TerminalInlineAssistId,
@@ -63,6 +64,7 @@ impl TerminalInlineAssistant {
project: WeakEntity<Project>,
thread_store: Entity<ThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ history: WeakEntity<AcpThreadHistory>,
initial_prompt: Option<String>,
window: &mut Window,
cx: &mut App,
@@ -88,6 +90,7 @@ impl TerminalInlineAssistant {
self.fs.clone(),
thread_store.clone(),
prompt_store.clone(),
+ history,
project.clone(),
workspace.clone(),
window,
@@ -24,23 +24,16 @@ agent_servers.workspace = true
agent_settings.workspace = true
agent_ui.workspace = true
anyhow.workspace = true
-chrono.workspace = true
db.workspace = true
-editor.workspace = true
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
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
-text.workspace = true
-time.workspace = true
-time_format.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
@@ -3,7 +3,7 @@ use agent::{NativeAgentServer, ThreadStore};
use agent_client_protocol as acp;
use agent_servers::AgentServer;
use agent_settings::AgentSettings;
-use agent_ui::acp::AcpThreadView;
+use agent_ui::acp::{AcpThreadHistory, AcpThreadView};
use fs::Fs;
use gpui::{
Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*,
@@ -62,10 +62,15 @@ pub struct AgentThreadPane {
width: Option<Pixels>,
thread_view: Option<ActiveThreadView>,
workspace: WeakEntity<Workspace>,
+ history: Entity<AcpThreadHistory>,
}
impl AgentThreadPane {
- pub fn new(workspace: WeakEntity<Workspace>, cx: &mut ui::Context<Self>) -> Self {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ history: Entity<AcpThreadHistory>,
+ cx: &mut ui::Context<Self>,
+ ) -> Self {
let focus_handle = cx.focus_handle();
Self {
focus_handle,
@@ -73,6 +78,7 @@ impl AgentThreadPane {
width: None,
thread_view: None,
workspace,
+ history,
}
}
@@ -104,6 +110,7 @@ impl AgentThreadPane {
let agent: Rc<dyn AgentServer> = Rc::new(NativeAgentServer::new(fs, thread_store.clone()));
+ let history = self.history.clone();
let thread_view = cx.new(|cx| {
AcpThreadView::new(
agent,
@@ -113,6 +120,7 @@ impl AgentThreadPane {
project,
Some(thread_store),
prompt_store,
+ history,
true,
window,
cx,
@@ -1,4 +1,3 @@
mod agent_thread_pane;
-mod thread_history;
pub mod agents_panel;
@@ -27,7 +27,7 @@ use workspace::{
use crate::agent_thread_pane::{
AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId,
};
-use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent};
+use agent_ui::acp::{AcpThreadHistory, ThreadHistoryEvent};
const AGENTS_PANEL_KEY: &str = "agents_panel";
@@ -310,9 +310,10 @@ impl AgentsPanel {
let project = self.project.clone();
let thread_store = self.thread_store.clone();
let prompt_store = self.prompt_store.clone();
+ let history = self.history.clone();
let agent_thread_pane = cx.new(|cx| {
- let mut pane = AgentThreadPane::new(workspace.clone(), cx);
+ let mut pane = AgentThreadPane::new(workspace.clone(), history, cx);
pane.open_thread(
entry,
fs,
@@ -1,868 +0,0 @@
-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;
-use gpui::{
- App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
- UniformListScrollHandle, Window, actions, uniform_list,
-};
-use std::{fmt::Display, ops::Range, rc::Rc};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
- HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar,
- prelude::*,
-};
-
-const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
-
-fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
- entry
- .title
- .as_ref()
- .filter(|title| !title.is_empty())
- .unwrap_or(DEFAULT_TITLE)
-}
-
-actions!(
- agents,
- [
- /// Removes all thread history.
- RemoveHistory,
- /// Removes the currently selected thread.
- RemoveSelectedThread,
- ]
-);
-
-pub struct AcpThreadHistory {
- session_list: Option<Rc<dyn AgentSessionList>>,
- sessions: Vec<AgentSessionInfo>,
- scroll_handle: UniformListScrollHandle,
- selected_index: usize,
- hovered_index: Option<usize>,
- search_editor: Entity<Editor>,
- search_query: SharedString,
- visible_items: Vec<ListItemType>,
- local_timezone: UtcOffset,
- confirming_delete_history: bool,
- _update_task: Task<()>,
- _watch_task: Option<Task<()>>,
- _subscriptions: Vec<gpui::Subscription>,
-}
-
-enum ListItemType {
- BucketSeparator(TimeBucket),
- Entry {
- entry: AgentSessionInfo,
- format: EntryTimeFormat,
- },
- SearchResult {
- entry: AgentSessionInfo,
- positions: Vec<usize>,
- },
-}
-
-impl ListItemType {
- fn history_entry(&self) -> Option<&AgentSessionInfo> {
- match self {
- ListItemType::Entry { entry, .. } => Some(entry),
- ListItemType::SearchResult { entry, .. } => Some(entry),
- _ => None,
- }
- }
-}
-
-#[allow(dead_code)]
-pub enum ThreadHistoryEvent {
- Open(AgentSessionInfo),
-}
-
-impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
-
-impl AcpThreadHistory {
- pub fn new(
- session_list: Option<Rc<dyn AgentSessionList>>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let search_editor = cx.new(|cx| {
- let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search threads...", window, cx);
- editor
- });
-
- let search_editor_subscription =
- cx.subscribe(&search_editor, |this, search_editor, event, cx| {
- if let EditorEvent::BufferEdited = event {
- let query = search_editor.read(cx).text(cx);
- if this.search_query != query {
- this.search_query = query.into();
- this.update_visible_items(false, cx);
- }
- }
- });
-
- let scroll_handle = UniformListScrollHandle::default();
-
- let mut this = Self {
- session_list: None,
- sessions: Vec::new(),
- scroll_handle,
- selected_index: 0,
- hovered_index: None,
- visible_items: Default::default(),
- search_editor,
- local_timezone: UtcOffset::from_whole_seconds(
- chrono::Local::now().offset().local_minus_utc(),
- )
- .unwrap(),
- search_query: SharedString::default(),
- confirming_delete_history: false,
- _subscriptions: vec![search_editor_subscription],
- _update_task: Task::ready(()),
- _watch_task: None,
- };
- 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.sessions.clone();
- let new_list_items = if self.search_query.is_empty() {
- self.add_list_separators(entries, cx)
- } else {
- self.filter_search_results(entries, cx)
- };
- let selected_history_entry = if preserve_selected_item {
- self.selected_history_entry().cloned()
- } else {
- None
- };
-
- self._update_task = cx.spawn(async move |this, cx| {
- let new_visible_items = new_list_items.await;
- this.update(cx, |this, cx| {
- let new_selected_index = if let Some(history_entry) = selected_history_entry {
- new_visible_items
- .iter()
- .position(|visible_entry| {
- visible_entry
- .history_entry()
- .is_some_and(|entry| entry.session_id == history_entry.session_id)
- })
- .unwrap_or(0)
- } else {
- 0
- };
-
- this.visible_items = new_visible_items;
- this.set_selected_index(new_selected_index, Bias::Right, cx);
- cx.notify();
- })
- .ok();
- });
- }
-
- 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<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- cx.background_spawn(async move {
- let mut items = Vec::with_capacity(entries.len() + 1);
- let mut bucket = None;
- let today = Local::now().naive_local().date();
-
- for entry in entries.into_iter() {
- 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);
- items.push(ListItemType::BucketSeparator(entry_bucket));
- }
-
- items.push(ListItemType::Entry {
- entry,
- format: entry_bucket.into(),
- });
- }
- items
- })
- }
-
- fn filter_search_results(
- &self,
- entries: Vec<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- let query = self.search_query.clone();
- cx.background_spawn({
- let executor = cx.background_executor().clone();
- async move {
- let mut candidates = Vec::with_capacity(entries.len());
-
- for (idx, entry) in entries.iter().enumerate() {
- candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
- }
-
- const MAX_MATCHES: usize = 100;
-
- let matches = fuzzy::match_strings(
- &candidates,
- &query,
- false,
- true,
- MAX_MATCHES,
- &Default::default(),
- executor,
- )
- .await;
-
- matches
- .into_iter()
- .map(|search_match| ListItemType::SearchResult {
- entry: entries[search_match.candidate_id].clone(),
- positions: search_match.positions,
- })
- .collect()
- }
- })
- }
-
- fn search_produced_no_matches(&self) -> bool {
- self.visible_items.is_empty() && !self.search_query.is_empty()
- }
-
- fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
- self.get_history_entry(self.selected_index)
- }
-
- fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
- self.visible_items.get(visible_items_ix)?.history_entry()
- }
-
- fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
- if self.visible_items.is_empty() {
- self.selected_index = 0;
- return;
- }
- while matches!(
- self.visible_items.get(index),
- None | Some(ListItemType::BucketSeparator(..))
- ) {
- index = match bias {
- Bias::Left => {
- if index == 0 {
- self.visible_items.len() - 1
- } else {
- index - 1
- }
- }
- Bias::Right => {
- if index >= self.visible_items.len() - 1 {
- 0
- } else {
- index + 1
- }
- }
- };
- }
- self.selected_index = index;
- self.scroll_handle
- .scroll_to_item(index, ScrollStrategy::Top);
- cx.notify()
- }
-
- pub fn select_previous(
- &mut self,
- _: &menu::SelectPrevious,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_index == 0 {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- } else {
- self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
- }
- }
-
- pub fn select_next(
- &mut self,
- _: &menu::SelectNext,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_index == self.visible_items.len() - 1 {
- self.set_selected_index(0, Bias::Right, cx);
- } else {
- self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
- }
- }
-
- fn select_first(
- &mut self,
- _: &menu::SelectFirst,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.set_selected_index(0, Bias::Right, cx);
- }
-
- fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- }
-
- fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirm_entry(self.selected_index, cx);
- }
-
- fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(ix) else {
- return;
- };
- cx.emit(ThreadHistoryEvent::Open(entry.clone()));
- }
-
- fn remove_selected_thread(
- &mut self,
- _: &RemoveSelectedThread,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.remove_thread(self.selected_index, cx)
- }
-
- fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(visible_item_ix) else {
- return;
- };
- 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>) {
- 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();
- }
-
- fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = true;
- cx.notify();
- }
-
- fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn render_list_items(
- &mut self,
- range: Range<usize>,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Vec<AnyElement> {
- self.visible_items
- .get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
- .collect()
- }
-
- fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
- match item {
- ListItemType::Entry { entry, format } => self
- .render_history_entry(entry, *format, ix, Vec::default(), cx)
- .into_any(),
- ListItemType::SearchResult { entry, positions } => self.render_history_entry(
- entry,
- EntryTimeFormat::DateAndTime,
- ix,
- positions.clone(),
- cx,
- ),
- ListItemType::BucketSeparator(bucket) => div()
- .px(DynamicSpacing::Base06.rems(cx))
- .pt_2()
- .pb_1()
- .child(
- Label::new(bucket.to_string())
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- }
- }
-
- fn render_history_entry(
- &self,
- entry: &AgentSessionInfo,
- format: EntryTimeFormat,
- ix: usize,
- highlight_positions: Vec<usize>,
- cx: &Context<Self>,
- ) -> AnyElement {
- let selected = ix == self.selected_index;
- let hovered = Some(ix) == self.hovered_index;
- 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, Some(entry_time)) => {
- format.format_timestamp(entry_time.timestamp(), self.local_timezone)
- }
- (_, None) => "—".to_string(),
- };
-
- let title = thread_title(entry).clone();
- 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()
- .pb_1()
- .child(
- ListItem::new(ix)
- .rounded()
- .toggle_state(selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(
- HighlightedLabel::new(thread_title(entry), highlight_positions)
- .size(LabelSize::Small)
- .truncate(),
- )
- .child(
- Label::new(display_text)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .tooltip(move |_, cx| {
- Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
- })
- .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
- if *is_hovered {
- this.hovered_index = Some(ix);
- } else if this.hovered_index == Some(ix) {
- this.hovered_index = None;
- }
-
- cx.notify();
- }))
- .end_slot::<IconButton>(if hovered {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click(cx.listener(move |this, _, _, cx| {
- this.remove_thread(ix, cx);
- cx.stop_propagation()
- })),
- )
- } else {
- None
- })
- .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
- )
- .into_any_element()
- }
-}
-
-impl Focusable for AcpThreadHistory {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.search_editor.focus_handle(cx)
- }
-}
-
-impl Render for AcpThreadHistory {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let has_no_history = self.is_empty();
-
- v_flex()
- .key_context("ThreadHistory")
- .size_full()
- .bg(cx.theme().colors().panel_background)
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .on_action(cx.listener(Self::select_first))
- .on_action(cx.listener(Self::select_last))
- .on_action(cx.listener(Self::confirm))
- .on_action(cx.listener(Self::remove_selected_thread))
- .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
- this.remove_history(window, cx);
- }))
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .w_full()
- .py_1()
- .px_2()
- .gap_2()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- Icon::new(IconName::MagnifyingGlass)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(self.search_editor.clone()),
- )
- .child({
- let view = v_flex()
- .id("list-container")
- .relative()
- .overflow_hidden()
- .flex_grow();
-
- if has_no_history {
- view.justify_center().items_center().child(
- Label::new("You don't have any past threads yet.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- } else if self.search_produced_no_matches() {
- view.justify_center()
- .items_center()
- .child(Label::new("No threads match your search.").size(LabelSize::Small))
- } else {
- view.child(
- uniform_list(
- "thread-history",
- self.visible_items.len(),
- cx.processor(|this, range: Range<usize>, window, cx| {
- this.render_list_items(range, window, cx)
- }),
- )
- .p_1()
- .pr_4()
- .track_scroll(&self.scroll_handle)
- .flex_grow(),
- )
- .vertical_scrollbar_for(&self.scroll_handle, window, cx)
- }
- })
- .when(!has_no_history, |this| {
- this.child(
- h_flex()
- .p_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .when(!self.confirming_delete_history, |this| {
- this.child(
- Button::new("delete_history", "Delete All History")
- .full_width()
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.prompt_delete_history(window, cx);
- })),
- )
- })
- .when(self.confirming_delete_history, |this| {
- this.w_full()
- .gap_2()
- .flex_wrap()
- .justify_between()
- .child(
- h_flex()
- .flex_wrap()
- .gap_1()
- .child(
- Label::new("Delete all threads?")
- .size(LabelSize::Small),
- )
- .child(
- Label::new("You won't be able to recover them later.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("cancel_delete", "Cancel")
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.cancel_delete_history(window, cx);
- })),
- )
- .child(
- Button::new("confirm_delete", "Delete")
- .style(ButtonStyle::Tinted(ui::TintColor::Error))
- .color(Color::Error)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|_, _, window, cx| {
- window.dispatch_action(
- Box::new(RemoveHistory),
- cx,
- );
- })),
- ),
- )
- }),
- )
- })
- }
-}
-
-#[derive(Clone, Copy)]
-pub enum EntryTimeFormat {
- DateAndTime,
- TimeOnly,
-}
-
-impl EntryTimeFormat {
- fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
- let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
-
- match self {
- EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
- timestamp,
- OffsetDateTime::now_utc(),
- timezone,
- time_format::TimestampFormat::EnhancedAbsolute,
- ),
- EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
- }
- }
-}
-
-impl From<TimeBucket> for EntryTimeFormat {
- fn from(bucket: TimeBucket) -> Self {
- match bucket {
- TimeBucket::Today => EntryTimeFormat::TimeOnly,
- TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
- TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::All => EntryTimeFormat::DateAndTime,
- }
- }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy, Debug)]
-enum TimeBucket {
- Today,
- Yesterday,
- ThisWeek,
- PastWeek,
- All,
-}
-
-impl TimeBucket {
- fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
- if date == reference {
- return TimeBucket::Today;
- }
-
- if date == reference - TimeDelta::days(1) {
- return TimeBucket::Yesterday;
- }
-
- let week = date.iso_week();
-
- if reference.iso_week() == week {
- return TimeBucket::ThisWeek;
- }
-
- let last_week = (reference - TimeDelta::days(7)).iso_week();
-
- if week == last_week {
- return TimeBucket::PastWeek;
- }
-
- TimeBucket::All
- }
-}
-
-impl Display for TimeBucket {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- TimeBucket::Today => write!(f, "Today"),
- TimeBucket::Yesterday => write!(f, "Yesterday"),
- TimeBucket::ThisWeek => write!(f, "This Week"),
- TimeBucket::PastWeek => write!(f, "Past Week"),
- TimeBucket::All => write!(f, "All"),
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use chrono::NaiveDate;
-
- #[test]
- fn test_time_bucket_from_dates() {
- let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
-
- let date = today;
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
-
- let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
-
- // All: not in this week or last week
- let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
- assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
-
- // Test year boundary cases
- let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
-
- let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
- assert_eq!(
- TimeBucket::from_dates(new_year, date),
- TimeBucket::Yesterday
- );
-
- let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
- assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
- }
-}