agent_ui: Move UI logic from `ThreadHistory` to separate component (#51301)

Bennet Bo Fenner created

This is just a re-factor. We'll make use of this once we make thread
history non-global (storing one history per ACP connection).

Release Notes:

- N/A

Change summary

crates/agent_ui/src/agent_panel.rs                 |  15 
crates/agent_ui/src/agent_ui.rs                    |   4 
crates/agent_ui/src/connection_view.rs             |  16 
crates/agent_ui/src/connection_view/thread_view.rs |   2 
crates/agent_ui/src/entry_view_state.rs            |   3 
crates/agent_ui/src/inline_assistant.rs            |   2 
crates/agent_ui/src/message_editor.rs              |  48 
crates/agent_ui/src/thread_history.rs              | 880 ---------------
crates/agent_ui/src/thread_history_view.rs         | 878 +++++++++++++++
9 files changed, 937 insertions(+), 911 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -48,7 +48,7 @@ use crate::{
     NewNativeAgentThreadFromSummary,
 };
 use crate::{
-    ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
+    ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent,
     text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
 };
 use agent_settings::AgentSettings;
@@ -863,6 +863,7 @@ pub struct AgentPanel {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
     acp_history: Entity<ThreadHistory>,
+    acp_history_view: Entity<ThreadHistoryView>,
     text_thread_history: Entity<TextThreadHistory>,
     thread_store: Entity<ThreadStore>,
     text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
@@ -1072,14 +1073,15 @@ impl AgentPanel {
             cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 
         let thread_store = ThreadStore::global(cx);
-        let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
+        let acp_history = cx.new(|cx| ThreadHistory::new(None, cx));
+        let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx));
         let text_thread_history =
             cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
         cx.subscribe_in(
-            &acp_history,
+            &acp_history_view,
             window,
             |this, _, event, window, cx| match event {
-                ThreadHistoryEvent::Open(thread) => {
+                ThreadHistoryViewEvent::Open(thread) => {
                     this.load_agent_thread(
                         thread.session_id.clone(),
                         thread.cwd.clone(),
@@ -1213,6 +1215,7 @@ impl AgentPanel {
             pending_serialization: None,
             onboarding,
             acp_history,
+            acp_history_view,
             text_thread_history,
             thread_store,
             selected_agent: AgentType::default(),
@@ -3046,7 +3049,7 @@ impl Focusable for AgentPanel {
             ActiveView::Uninitialized => self.focus_handle.clone(),
             ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx),
             ActiveView::History { kind } => match kind {
-                HistoryKind::AgentThreads => self.acp_history.focus_handle(cx),
+                HistoryKind::AgentThreads => self.acp_history_view.focus_handle(cx),
                 HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
             },
             ActiveView::TextThread {
@@ -4763,7 +4766,7 @@ impl Render for AgentPanel {
                         .child(server_view.clone())
                         .child(self.render_drag_target(cx)),
                     ActiveView::History { kind } => match kind {
-                        HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
+                        HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()),
                         HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
                     },
                     ActiveView::TextThread {

crates/agent_ui/src/agent_ui.rs 🔗

@@ -33,6 +33,7 @@ pub mod test_support;
 mod text_thread_editor;
 mod text_thread_history;
 mod thread_history;
+mod thread_history_view;
 mod ui;
 
 use std::rc::Rc;
@@ -74,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector;
 pub(crate) use model_selector::ModelSelector;
 pub(crate) use model_selector_popover::ModelSelectorPopover;
 pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
-pub(crate) use thread_history::*;
+pub(crate) use thread_history::ThreadHistory;
+pub(crate) use thread_history_view::*;
 use zed_actions;
 
 actions!(

crates/agent_ui/src/connection_view.rs 🔗

@@ -2901,7 +2901,7 @@ pub(crate) mod tests {
 
         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| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -3007,7 +3007,7 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -3066,7 +3066,7 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -3123,7 +3123,7 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -3180,7 +3180,7 @@ pub(crate) mod tests {
         let captured_cwd = connection.captured_cwd.clone();
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -3498,7 +3498,7 @@ pub(crate) mod tests {
 
         // Set up thread view in workspace 1
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
 
@@ -3718,7 +3718,7 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 
@@ -4454,7 +4454,7 @@ pub(crate) mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
-        let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
         let connection_store =
             cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
 

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -7409,7 +7409,7 @@ impl ThreadView {
                                     // TODO: Add keyboard navigation.
                                     let is_hovered =
                                         self.hovered_recent_history_item == Some(index);
-                                    crate::thread_history::HistoryEntryElement::new(
+                                    crate::thread_history_view::HistoryEntryElement::new(
                                         entry,
                                         self.server_view.clone(),
                                     )

crates/agent_ui/src/entry_view_state.rs 🔗

@@ -508,8 +508,7 @@ mod tests {
         });
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let view_state = cx.new(|_cx| {
             EntryViewState::new(

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -2155,7 +2155,7 @@ pub mod test {
             });
 
             let thread_store = cx.new(|cx| ThreadStore::new(cx));
-            let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx));
+            let history = cx.new(|cx| crate::ThreadHistory::new(None, cx));
 
             // Add editor to workspace
             workspace.update(cx, |workspace, cx| {

crates/agent_ui/src/message_editor.rs 🔗

@@ -1708,8 +1708,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -1822,8 +1821,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let workspace_handle = workspace.downgrade();
         let message_editor = workspace.update_in(cx, |_, window, cx| {
             cx.new(|cx| {
@@ -1978,8 +1976,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, 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"),
@@ -2213,8 +2210,7 @@ mod tests {
         }
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
         let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -2709,8 +2705,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2810,8 +2805,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let session_id = acp::SessionId::new("thread-123");
         let title = Some("Previous Conversation".into());
@@ -2886,8 +2880,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2943,8 +2936,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = None;
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -2998,8 +2990,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3054,8 +3045,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3119,8 +3109,7 @@ mod tests {
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3279,8 +3268,7 @@ mod tests {
         });
 
         let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
         // to ensure we have a fixed viewport, so we can eventually actually
@@ -3400,8 +3388,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3483,8 +3470,7 @@ mod tests {
         let mut cx = VisualTestContext::from_window(window.into(), cx);
 
         let thread_store = cx.new(|cx| ThreadStore::new(cx));
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
             let workspace_handle = cx.weak_entity();
@@ -3568,8 +3554,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {
@@ -3721,8 +3706,7 @@ mod tests {
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
         let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
-        let history =
-            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+        let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
 
         let message_editor = cx.update(|window, cx| {
             cx.new(|cx| {

crates/agent_ui/src/thread_history.rs 🔗

@@ -1,118 +1,21 @@
-use crate::ConnectionView;
-use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
 use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
 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, WeakEntity, Window, uniform_list,
-};
-use std::{fmt::Display, ops::Range, rc::Rc};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
-    ElementId, 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)
-}
+use gpui::{App, Task};
+use std::rc::Rc;
+use ui::prelude::*;
 
 pub struct ThreadHistory {
     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,
-    _visible_items_task: Task<()>,
     _refresh_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,
-        }
-    }
 }
 
-pub enum ThreadHistoryEvent {
-    Open(AgentSessionInfo),
-}
-
-impl EventEmitter<ThreadHistoryEvent> for ThreadHistory {}
-
 impl ThreadHistory {
-    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();
-
+    pub fn new(session_list: Option<Rc<dyn AgentSessionList>>, cx: &mut Context<Self>) -> Self {
         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],
-            _visible_items_task: Task::ready(()),
             _refresh_task: Task::ready(()),
             _watch_task: None,
         };
@@ -120,43 +23,6 @@ impl ThreadHistory {
         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._visible_items_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 fn set_session_list(
         &mut self,
         session_list: Option<Rc<dyn AgentSessionList>>,
@@ -170,9 +36,6 @@ impl ThreadHistory {
 
         self.session_list = session_list;
         self.sessions.clear();
-        self.visible_items.clear();
-        self.selected_index = 0;
-        self._visible_items_task = Task::ready(());
         self._refresh_task = Task::ready(());
 
         let Some(session_list) = self.session_list.as_ref() else {
@@ -181,9 +44,8 @@ impl ThreadHistory {
             return;
         };
         let Some(rx) = session_list.watch(cx) else {
-            // No watch support - do a one-time refresh
             self._watch_task = None;
-            self.refresh_sessions(false, false, cx);
+            self.refresh_sessions(false, cx);
             return;
         };
         session_list.notify_refresh();
@@ -191,7 +53,6 @@ impl ThreadHistory {
         self._watch_task = Some(cx.spawn(async move |this, cx| {
             while let Ok(first_update) = rx.recv().await {
                 let mut updates = vec![first_update];
-                // Collect any additional updates that are already in the channel
                 while let Ok(update) = rx.try_recv() {
                     updates.push(update);
                 }
@@ -202,7 +63,7 @@ impl ThreadHistory {
                         .any(|u| matches!(u, SessionListUpdate::Refresh));
 
                     if needs_refresh {
-                        this.refresh_sessions(true, false, cx);
+                        this.refresh_sessions(false, cx);
                     } else {
                         for update in updates {
                             if let SessionListUpdate::SessionInfo { session_id, update } = update {
@@ -217,7 +78,7 @@ impl ThreadHistory {
     }
 
     pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
-        self.refresh_sessions(true, true, cx);
+        self.refresh_sessions(true, cx);
     }
 
     fn apply_info_update(
@@ -258,23 +119,15 @@ impl ThreadHistory {
             session.meta = Some(meta);
         }
 
-        self.update_visible_items(true, cx);
+        cx.notify();
     }
 
-    fn refresh_sessions(
-        &mut self,
-        preserve_selected_item: bool,
-        load_all_pages: bool,
-        cx: &mut Context<Self>,
-    ) {
+    fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context<Self>) {
         let Some(session_list) = self.session_list.clone() else {
-            self.update_visible_items(preserve_selected_item, cx);
+            cx.notify();
             return;
         };
 
-        // If a new refresh arrives while pagination is in progress, the previous
-        // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
-        // but means sessions may be in a partial state until the new refresh completes.
         self._refresh_task = cx.spawn(async move |this, cx| {
             let mut cursor: Option<String> = None;
             let mut is_first_page = true;
@@ -305,7 +158,7 @@ impl ThreadHistory {
                     } else {
                         this.sessions.extend(page_sessions);
                     }
-                    this.update_visible_items(preserve_selected_item, cx);
+                    cx.notify();
                 })
                 .ok();
 
@@ -378,693 +231,11 @@ impl ThreadHistory {
         }
     }
 
-    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.len() == 0 {
-            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);
+    pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task<anyhow::Result<()>> {
+        if let Some(session_list) = self.session_list.as_ref() {
+            session_list.delete_sessions(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;
-        };
-        if !session_list.supports_delete() {
-            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>) {
-        let Some(session_list) = self.session_list.as_ref() else {
-            return;
-        };
-        if !session_list.supports_delete() {
-            return;
-        }
-        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 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, 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_time
-            .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 && self.supports_delete() {
-                        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 ThreadHistory {
-    fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.search_editor.focus_handle(cx)
-    }
-}
-
-impl Render for ThreadHistory {
-    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 && self.supports_delete(), |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(IntoElement)]
-pub struct HistoryEntryElement {
-    entry: AgentSessionInfo,
-    thread_view: WeakEntity<ConnectionView>,
-    selected: bool,
-    hovered: bool,
-    supports_delete: bool,
-    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
-}
-
-impl HistoryEntryElement {
-    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
-        Self {
-            entry,
-            thread_view,
-            selected: false,
-            hovered: false,
-            supports_delete: false,
-            on_hover: Box::new(|_, _, _| {}),
-        }
-    }
-
-    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
-        self.supports_delete = supports_delete;
-        self
-    }
-
-    pub fn hovered(mut self, hovered: bool) -> Self {
-        self.hovered = hovered;
-        self
-    }
-
-    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
-        self.on_hover = Box::new(on_hover);
-        self
-    }
-}
-
-impl RenderOnce for HistoryEntryElement {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let id = ElementId::Name(self.entry.session_id.0.clone().into());
-        let title = thread_title(&self.entry).clone();
-        let formatted_time = self
-            .entry
-            .updated_at
-            .map(|timestamp| {
-                let now = chrono::Utc::now();
-                let duration = now.signed_duration_since(timestamp);
-
-                if duration.num_days() > 0 {
-                    format!("{}d", duration.num_days())
-                } else if duration.num_hours() > 0 {
-                    format!("{}h ago", duration.num_hours())
-                } else if duration.num_minutes() > 0 {
-                    format!("{}m ago", duration.num_minutes())
-                } else {
-                    "Just now".to_string()
-                }
-            })
-            .unwrap_or_else(|| "Unknown".to_string());
-
-        ListItem::new(id)
-            .rounded()
-            .toggle_state(self.selected)
-            .spacing(ListItemSpacing::Sparse)
-            .start_slot(
-                h_flex()
-                    .w_full()
-                    .gap_2()
-                    .justify_between()
-                    .child(Label::new(title).size(LabelSize::Small).truncate())
-                    .child(
-                        Label::new(formatted_time)
-                            .color(Color::Muted)
-                            .size(LabelSize::XSmall),
-                    ),
-            )
-            .on_hover(self.on_hover)
-            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
-                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({
-                            let thread_view = self.thread_view.clone();
-                            let session_id = self.entry.session_id.clone();
-
-                            move |_event, _window, cx| {
-                                if let Some(thread_view) = thread_view.upgrade() {
-                                    thread_view.update(cx, |thread_view, cx| {
-                                        thread_view.delete_history_entry(&session_id, cx);
-                                    });
-                                }
-                            }
-                        }),
-                )
-            } else {
-                None
-            })
-            .on_click({
-                let thread_view = self.thread_view.clone();
-                let entry = self.entry;
-
-                move |_event, window, cx| {
-                    if let Some(workspace) = thread_view
-                        .upgrade()
-                        .and_then(|view| view.read(cx).workspace().upgrade())
-                    {
-                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
-                            panel.update(cx, |panel, cx| {
-                                panel.load_agent_thread(
-                                    entry.session_id.clone(),
-                                    entry.cwd.clone(),
-                                    entry.title.clone(),
-                                    window,
-                                    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"),
+            Task::ready(Ok(()))
         }
     }
 }
@@ -1073,7 +244,6 @@ impl Display for TimeBucket {
 mod tests {
     use super::*;
     use acp_thread::AgentSessionListResponse;
-    use chrono::NaiveDate;
     use gpui::TestAppContext;
     use std::{
         any::Any,
@@ -1246,9 +416,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, _cx| {
@@ -1270,9 +438,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
         session_list.clear_requested_cursors();
 
@@ -1307,9 +473,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1340,9 +504,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1371,9 +533,7 @@ mod tests {
             vec![test_session("session-2", "Second")],
         ));
 
-        let (history, cx) = cx.add_window_view(|window, cx| {
-            ThreadHistory::new(Some(session_list.clone()), window, cx)
-        });
+        let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
         cx.run_until_parked();
 
         history.update(cx, |history, cx| history.refresh_full_history(cx));

crates/agent_ui/src/thread_history_view.rs 🔗

@@ -0,0 +1,878 @@
+use crate::thread_history::ThreadHistory;
+use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread};
+use acp_thread::AgentSessionInfo;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+    AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
+    UniformListScrollHandle, WeakEntity, Window, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
+    WithScrollbar, prelude::*,
+};
+
+const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
+
+pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
+    entry
+        .title
+        .as_ref()
+        .filter(|title| !title.is_empty())
+        .unwrap_or(DEFAULT_TITLE)
+}
+
+pub struct ThreadHistoryView {
+    history: Entity<ThreadHistory>,
+    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,
+    _visible_items_task: 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,
+        }
+    }
+}
+
+pub enum ThreadHistoryViewEvent {
+    Open(AgentSessionInfo),
+}
+
+impl EventEmitter<ThreadHistoryViewEvent> for ThreadHistoryView {}
+
+impl ThreadHistoryView {
+    pub fn new(
+        history: Entity<ThreadHistory>,
+        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 history_subscription = cx.observe(&history, |this, _, cx| {
+            this.update_visible_items(true, cx);
+        });
+
+        let scroll_handle = UniformListScrollHandle::default();
+
+        let mut this = Self {
+            history,
+            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, history_subscription],
+            _visible_items_task: Task::ready(()),
+        };
+        this.update_visible_items(false, cx);
+        this
+    }
+
+    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
+        let entries = self.history.read(cx).sessions().to_vec();
+        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._visible_items_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();
+        });
+    }
+
+    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.len() == 0 {
+            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()
+    }
+
+    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);
+        }
+    }
+
+    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(ThreadHistoryViewEvent::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;
+        };
+        if !self.history.read(cx).supports_delete() {
+            return;
+        }
+        let session_id = entry.session_id.clone();
+        self.history.update(cx, |history, cx| {
+            history
+                .delete_session(&session_id, cx)
+                .detach_and_log_err(cx);
+        });
+    }
+
+    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if !self.history.read(cx).supports_delete() {
+            return;
+        }
+        self.history.update(cx, |history, cx| {
+            history.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 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, 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_time
+            .map(|time| {
+                EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
+            })
+            .unwrap_or_else(|| "Unknown".to_string());
+
+        let supports_delete = self.history.read(cx).supports_delete();
+
+        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 && supports_delete {
+                        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 ThreadHistoryView {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.search_editor.focus_handle(cx)
+    }
+}
+
+impl Render for ThreadHistoryView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_no_history = self.history.read(cx).is_empty();
+        let supports_delete = self.history.read(cx).supports_delete();
+
+        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 && supports_delete, |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(IntoElement)]
+pub struct HistoryEntryElement {
+    entry: AgentSessionInfo,
+    thread_view: WeakEntity<ConnectionView>,
+    selected: bool,
+    hovered: bool,
+    supports_delete: bool,
+    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+}
+
+impl HistoryEntryElement {
+    pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
+        Self {
+            entry,
+            thread_view,
+            selected: false,
+            hovered: false,
+            supports_delete: false,
+            on_hover: Box::new(|_, _, _| {}),
+        }
+    }
+
+    pub fn supports_delete(mut self, supports_delete: bool) -> Self {
+        self.supports_delete = supports_delete;
+        self
+    }
+
+    pub fn hovered(mut self, hovered: bool) -> Self {
+        self.hovered = hovered;
+        self
+    }
+
+    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
+        self.on_hover = Box::new(on_hover);
+        self
+    }
+}
+
+impl RenderOnce for HistoryEntryElement {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let id = ElementId::Name(self.entry.session_id.0.clone().into());
+        let title = thread_title(&self.entry).clone();
+        let formatted_time = self
+            .entry
+            .updated_at
+            .map(|timestamp| {
+                let now = chrono::Utc::now();
+                let duration = now.signed_duration_since(timestamp);
+
+                if duration.num_days() > 0 {
+                    format!("{}d", duration.num_days())
+                } else if duration.num_hours() > 0 {
+                    format!("{}h ago", duration.num_hours())
+                } else if duration.num_minutes() > 0 {
+                    format!("{}m ago", duration.num_minutes())
+                } else {
+                    "Just now".to_string()
+                }
+            })
+            .unwrap_or_else(|| "Unknown".to_string());
+
+        ListItem::new(id)
+            .rounded()
+            .toggle_state(self.selected)
+            .spacing(ListItemSpacing::Sparse)
+            .start_slot(
+                h_flex()
+                    .w_full()
+                    .gap_2()
+                    .justify_between()
+                    .child(Label::new(title).size(LabelSize::Small).truncate())
+                    .child(
+                        Label::new(formatted_time)
+                            .color(Color::Muted)
+                            .size(LabelSize::XSmall),
+                    ),
+            )
+            .on_hover(self.on_hover)
+            .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
+                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({
+                            let thread_view = self.thread_view.clone();
+                            let session_id = self.entry.session_id.clone();
+
+                            move |_event, _window, cx| {
+                                if let Some(thread_view) = thread_view.upgrade() {
+                                    thread_view.update(cx, |thread_view, cx| {
+                                        thread_view.delete_history_entry(&session_id, cx);
+                                    });
+                                }
+                            }
+                        }),
+                )
+            } else {
+                None
+            })
+            .on_click({
+                let thread_view = self.thread_view.clone();
+                let entry = self.entry;
+
+                move |_event, window, cx| {
+                    if let Some(workspace) = thread_view
+                        .upgrade()
+                        .and_then(|view| view.read(cx).workspace().upgrade())
+                    {
+                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                            panel.update(cx, |panel, cx| {
+                                panel.load_agent_thread(
+                                    entry.session_id.clone(),
+                                    entry.cwd.clone(),
+                                    entry.title.clone(),
+                                    window,
+                                    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(2025, 1, 15).unwrap();
+
+        assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today);
+
+        let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, yesterday),
+            TimeBucket::Yesterday
+        );
+
+        let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, this_week),
+            TimeBucket::ThisWeek
+        );
+
+        let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
+        assert_eq!(
+            TimeBucket::from_dates(today, past_week),
+            TimeBucket::PastWeek
+        );
+
+        let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
+        assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All);
+    }
+}