diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9ffe3f39d27c7fe5cbb532a4f263c8800398e96f
--- /dev/null
+++ b/assets/icons/archive.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index ea70d155b79e190dcfe9138b620aff4415b6d935..db0cf873418ea38f8d5771c13b281528218fb94e 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -34,6 +34,7 @@ mod text_thread_editor;
mod text_thread_history;
mod thread_history;
mod thread_history_view;
+mod threads_archive_view;
mod ui;
use std::rc::Rc;
diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs
index e204205819a8eb41a0624fb8a4a8ba9a96174add..2d4259717d160521ddd4884cbb6a1a1241456b64 100644
--- a/crates/agent_ui/src/sidebar.rs
+++ b/crates/agent_ui/src/sidebar.rs
@@ -1,3 +1,4 @@
+use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
use crate::{AgentPanel, AgentPanelEvent, NewThread};
use acp_thread::ThreadStatus;
use action_log::DiffStats;
@@ -6,19 +7,18 @@ use agent_client_protocol as acp;
use agent_settings::AgentSettings;
use chrono::Utc;
use db::kvp::KEY_VALUE_STORE;
-use editor::{Editor, EditorElement, EditorStyle};
+use editor::Editor;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
- Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState,
- Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
- relative, rems,
+ Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels,
+ Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px,
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::Event as ProjectEvent;
use settings::Settings;
use std::collections::{HashMap, HashSet};
use std::mem;
-use theme::{ActiveTheme, ThemeSettings};
+use theme::ActiveTheme;
use ui::{
AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
Tooltip, WithScrollbar, prelude::*,
@@ -46,6 +46,13 @@ const MAX_WIDTH: Pixels = px(800.0);
const DEFAULT_THREADS_SHOWN: usize = 5;
const SIDEBAR_STATE_KEY: &str = "sidebar_state";
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum SidebarView {
+ #[default]
+ ThreadList,
+ Archive,
+}
+
fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
KEY_VALUE_STORE
.scoped(SIDEBAR_STATE_KEY)
@@ -212,6 +219,9 @@ pub struct Sidebar {
active_entry_index: Option,
collapsed_groups: HashSet,
expanded_groups: HashMap,
+ view: SidebarView,
+ archive_view: Option>,
+ _subscriptions: Vec,
}
impl Sidebar {
@@ -311,6 +321,9 @@ impl Sidebar {
active_entry_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
+ view: SidebarView::default(),
+ archive_view: None,
+ _subscriptions: Vec::new(),
}
}
@@ -1323,28 +1336,8 @@ impl Sidebar {
.into_any_element()
}
- fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement {
- let settings = ThemeSettings::get_global(cx);
- let text_style = TextStyle {
- color: cx.theme().colors().text,
- font_family: settings.ui_font.family.clone(),
- font_features: settings.ui_font.features.clone(),
- font_fallbacks: settings.ui_font.fallbacks.clone(),
- font_size: rems(0.875).into(),
- font_weight: settings.ui_font.weight,
- font_style: FontStyle::Normal,
- line_height: relative(1.3),
- ..Default::default()
- };
-
- EditorElement::new(
- &self.filter_editor,
- EditorStyle {
- local_player: cx.theme().players().local(),
- text: text_style,
- ..Default::default()
- },
- )
+ fn render_filter_input(&self) -> impl IntoElement {
+ self.filter_editor.clone()
}
fn render_view_more(
@@ -1451,6 +1444,61 @@ impl Sidebar {
.into_any_element()
}
+ fn render_thread_list_header(
+ &self,
+ docked_right: bool,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let has_query = self.has_filter_query(cx);
+
+ h_flex()
+ .h(Tab::container_height(cx))
+ .flex_none()
+ .gap_1p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .when(!docked_right, |this| {
+ this.child(self.render_sidebar_toggle_button(false, cx))
+ })
+ .child(self.render_filter_input())
+ .when(has_query, |this| {
+ this.when(!docked_right, |this| this.pr_1p5()).child(
+ IconButton::new("clear_filter", IconName::Close)
+ .shape(IconButtonShape::Square)
+ .tooltip(Tooltip::text("Clear Search"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.reset_filter_editor_text(window, cx);
+ this.update_entries(cx);
+ })),
+ )
+ })
+ .when(docked_right, |this| {
+ this.pl_2()
+ .pr_0p5()
+ .child(self.render_sidebar_toggle_button(true, cx))
+ })
+ }
+
+ fn render_thread_list_footer(&self, cx: &mut Context) -> impl IntoElement {
+ h_flex()
+ .p_1p5()
+ .border_t_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Button::new("view-archive", "Archive")
+ .full_width()
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .icon(IconName::Archive)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::XSmall)
+ .icon_position(IconPosition::Start)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.show_archive(window, cx);
+ })),
+ )
+ }
+
fn render_sidebar_toggle_button(
&self,
docked_right: bool,
@@ -1491,6 +1539,67 @@ impl Sidebar {
self.is_open
}
+ fn show_archive(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
+ w.read(cx)
+ .workspaces()
+ .get(w.read(cx).active_workspace_index())
+ .cloned()
+ }) else {
+ return;
+ };
+
+ let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else {
+ return;
+ };
+
+ let thread_store = agent_panel.read(cx).thread_store().clone();
+ let fs = active_workspace.read(cx).project().read(cx).fs().clone();
+ let agent_connection_store = agent_panel.read(cx).connection_store().clone();
+ let agent_server_store = active_workspace
+ .read(cx)
+ .project()
+ .read(cx)
+ .agent_server_store()
+ .clone();
+
+ let archive_view = cx.new(|cx| {
+ ThreadsArchiveView::new(
+ agent_connection_store,
+ agent_server_store,
+ thread_store,
+ fs,
+ window,
+ cx,
+ )
+ });
+ let subscription = cx.subscribe_in(
+ &archive_view,
+ window,
+ |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event {
+ ThreadsArchiveViewEvent::Close => {
+ this.show_thread_list(window, cx);
+ }
+ ThreadsArchiveViewEvent::OpenThread(_session_info) => {
+ //TODO: Actually open thread once we support it
+ }
+ },
+ );
+
+ self._subscriptions.push(subscription);
+ self.archive_view = Some(archive_view);
+ self.view = SidebarView::Archive;
+ cx.notify();
+ }
+
+ fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) {
+ self.view = SidebarView::ThreadList;
+ self.archive_view = None;
+ self._subscriptions.clear();
+ window.focus(&self.focus_handle, cx);
+ cx.notify();
+ }
+
pub fn set_open(&mut self, open: bool, cx: &mut Context) {
if self.is_open == open {
return;
@@ -1558,7 +1667,6 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
let ui_font = theme::setup_ui_font(window, cx);
- let has_query = self.has_filter_query(cx);
let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
let sticky_header = self.render_sticky_header(docked_right, window, cx);
@@ -1579,50 +1687,34 @@ impl Render for Sidebar {
.font(ui_font)
.size_full()
.bg(cx.theme().colors().surface_background)
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .flex_none()
- .gap_1p5()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .when(!docked_right, |this| {
- this.child(self.render_sidebar_toggle_button(false, cx))
- })
- .child(self.render_filter_input(cx))
- .when(has_query, |this| {
- this.when(!docked_right, |this| this.pr_1p5()).child(
- IconButton::new("clear_filter", IconName::Close)
- .shape(IconButtonShape::Square)
- .tooltip(Tooltip::text("Clear Search"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.reset_filter_editor_text(window, cx);
- this.update_entries(cx);
- })),
- )
- })
- .when(docked_right, |this| {
- this.pl_2()
- .pr_0p5()
- .child(self.render_sidebar_toggle_button(true, cx))
- }),
- )
- .child(
- v_flex()
- .relative()
- .flex_1()
- .overflow_hidden()
+ .map(|this| match self.view {
+ SidebarView::ThreadList => this
+ .child(self.render_thread_list_header(docked_right, cx))
.child(
- list(
- self.list_state.clone(),
- cx.processor(Self::render_list_entry),
- )
- .flex_1()
- .size_full(),
+ v_flex()
+ .relative()
+ .flex_1()
+ .overflow_hidden()
+ .child(
+ list(
+ self.list_state.clone(),
+ cx.processor(Self::render_list_entry),
+ )
+ .flex_1()
+ .size_full(),
+ )
+ .when_some(sticky_header, |this, header| this.child(header))
+ .vertical_scrollbar_for(&self.list_state, window, cx),
)
- .when_some(sticky_header, |this, header| this.child(header))
- .vertical_scrollbar_for(&self.list_state, window, cx),
- )
+ .child(self.render_thread_list_footer(cx)),
+ SidebarView::Archive => {
+ if let Some(archive_view) = &self.archive_view {
+ this.child(archive_view.clone())
+ } else {
+ this
+ }
+ }
+ })
}
}
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8ee0eedbd8702c7901258087af5d149fcf210648
--- /dev/null
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -0,0 +1,654 @@
+use std::sync::Arc;
+
+use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory};
+use acp_thread::AgentSessionInfo;
+use agent::ThreadStore;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::Editor;
+use fs::Fs;
+use gpui::{
+ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
+ SharedString, Subscription, Task, Window, list, prelude::*, px,
+};
+use itertools::Itertools as _;
+use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use project::{AgentServerStore, ExternalAgentServerName};
+use theme::ActiveTheme;
+use ui::{
+ ButtonLike, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, PopoverMenu,
+ PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*,
+};
+use util::ResultExt as _;
+use zed_actions::editor::{MoveDown, MoveUp};
+
+#[derive(Clone)]
+enum ArchiveListItem {
+ BucketSeparator(TimeBucket),
+ Entry {
+ session: AgentSessionInfo,
+ highlight_positions: Vec,
+ },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TimeBucket {
+ Today,
+ Yesterday,
+ ThisWeek,
+ PastWeek,
+ Older,
+}
+
+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::Older
+ }
+
+ fn label(&self) -> &'static str {
+ match self {
+ TimeBucket::Today => "Today",
+ TimeBucket::Yesterday => "Yesterday",
+ TimeBucket::ThisWeek => "This Week",
+ TimeBucket::PastWeek => "Past Week",
+ TimeBucket::Older => "Older",
+ }
+ }
+}
+
+fn fuzzy_match_positions(query: &str, text: &str) -> Option> {
+ let query = query.to_lowercase();
+ let text_lower = text.to_lowercase();
+ let mut positions = Vec::new();
+ let mut query_chars = query.chars().peekable();
+ for (i, c) in text_lower.chars().enumerate() {
+ if query_chars.peek() == Some(&c) {
+ positions.push(i);
+ query_chars.next();
+ }
+ }
+ if query_chars.peek().is_none() {
+ Some(positions)
+ } else {
+ None
+ }
+}
+
+pub enum ThreadsArchiveViewEvent {
+ Close,
+ OpenThread(AgentSessionInfo),
+}
+
+impl EventEmitter for ThreadsArchiveView {}
+
+pub struct ThreadsArchiveView {
+ agent_connection_store: Entity,
+ agent_server_store: Entity,
+ thread_store: Entity,
+ fs: Arc,
+ history: Option>,
+ _history_subscription: Subscription,
+ selected_agent: Agent,
+ focus_handle: FocusHandle,
+ list_state: ListState,
+ items: Vec,
+ selection: Option,
+ filter_editor: Entity,
+ _subscriptions: Vec,
+ selected_agent_menu: PopoverMenuHandle,
+ _refresh_history_task: Task<()>,
+}
+
+impl ThreadsArchiveView {
+ pub fn new(
+ agent_connection_store: Entity,
+ agent_server_store: Entity,
+ thread_store: Entity,
+ fs: Arc,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let focus_handle = cx.focus_handle();
+
+ let filter_editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Search archiveā¦", window, cx);
+ editor
+ });
+
+ let filter_editor_subscription =
+ cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| {
+ if let editor::EditorEvent::BufferEdited = event {
+ this.update_items(cx);
+ }
+ });
+
+ let mut this = Self {
+ agent_connection_store,
+ agent_server_store,
+ thread_store,
+ fs,
+ history: None,
+ _history_subscription: Subscription::new(|| {}),
+ selected_agent: Agent::NativeAgent,
+ focus_handle,
+ list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
+ items: Vec::new(),
+ selection: None,
+ filter_editor,
+ _subscriptions: vec![filter_editor_subscription],
+ selected_agent_menu: PopoverMenuHandle::default(),
+ _refresh_history_task: Task::ready(()),
+ };
+ this.set_selected_agent(Agent::NativeAgent, cx);
+ this
+ }
+
+ fn set_selected_agent(&mut self, agent: Agent, cx: &mut Context) {
+ self.selected_agent = agent.clone();
+
+ let server = agent.server(self.fs.clone(), self.thread_store.clone());
+ let connection = self
+ .agent_connection_store
+ .update(cx, |store, cx| store.request_connection(agent, server, cx));
+
+ let task = connection.read(cx).wait_for_connection();
+ self._refresh_history_task = cx.spawn(async move |this, cx| {
+ if let Some(state) = task.await.log_err() {
+ this.update(cx, |this, cx| this.set_history(state.history, cx))
+ .ok();
+ }
+ });
+
+ cx.notify();
+ }
+
+ fn set_history(&mut self, history: Entity, cx: &mut Context) {
+ self._history_subscription = cx.observe(&history, |this, _, cx| {
+ this.update_items(cx);
+ });
+ history.update(cx, |history, cx| {
+ history.refresh_full_history(cx);
+ });
+ self.history = Some(history);
+ self.update_items(cx);
+ cx.notify();
+ }
+
+ fn update_items(&mut self, cx: &mut Context) {
+ let Some(history) = self.history.as_ref() else {
+ return;
+ };
+
+ let sessions = history.read(cx).sessions().to_vec();
+ let query = self.filter_editor.read(cx).text(cx).to_lowercase();
+ let today = Local::now().naive_local().date();
+
+ let mut items = Vec::with_capacity(sessions.len() + 5);
+ let mut current_bucket: Option = None;
+
+ for session in sessions {
+ let highlight_positions = if !query.is_empty() {
+ let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or("");
+ match fuzzy_match_positions(&query, title) {
+ Some(positions) => positions,
+ None => continue,
+ }
+ } else {
+ Vec::new()
+ };
+
+ let entry_bucket = session
+ .updated_at
+ .map(|timestamp| {
+ let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+ TimeBucket::from_dates(today, entry_date)
+ })
+ .unwrap_or(TimeBucket::Older);
+
+ if Some(entry_bucket) != current_bucket {
+ current_bucket = Some(entry_bucket);
+ items.push(ArchiveListItem::BucketSeparator(entry_bucket));
+ }
+
+ items.push(ArchiveListItem::Entry {
+ session,
+ highlight_positions,
+ });
+ }
+
+ self.list_state.reset(items.len());
+ self.items = items;
+ cx.notify();
+ }
+
+ fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) {
+ self.filter_editor.update(cx, |editor, cx| {
+ editor.set_text("", window, cx);
+ });
+ }
+
+ fn go_back(&mut self, window: &mut Window, cx: &mut Context) {
+ self.reset_filter_editor_text(window, cx);
+ cx.emit(ThreadsArchiveViewEvent::Close);
+ }
+
+ fn open_thread(
+ &mut self,
+ session_info: AgentSessionInfo,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.selection = None;
+ self.reset_filter_editor_text(window, cx);
+ cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info));
+ }
+
+ fn is_selectable_item(&self, ix: usize) -> bool {
+ matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
+ }
+
+ fn find_next_selectable(&self, start: usize) -> Option {
+ (start..self.items.len()).find(|&i| self.is_selectable_item(i))
+ }
+
+ fn find_previous_selectable(&self, start: usize) -> Option {
+ (0..=start).rev().find(|&i| self.is_selectable_item(i))
+ }
+
+ fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) {
+ self.select_next(&SelectNext, window, cx);
+ }
+
+ fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) {
+ self.select_previous(&SelectPrevious, window, cx);
+ }
+
+ fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) {
+ let next = match self.selection {
+ Some(ix) => self.find_next_selectable(ix + 1),
+ None => self.find_next_selectable(0),
+ };
+ if let Some(next) = next {
+ self.selection = Some(next);
+ self.list_state.scroll_to_reveal_item(next);
+ cx.notify();
+ }
+ }
+
+ fn select_previous(
+ &mut self,
+ _: &SelectPrevious,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let prev = match self.selection {
+ Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1),
+ None => {
+ let last = self.items.len().saturating_sub(1);
+ self.find_previous_selectable(last)
+ }
+ _ => return,
+ };
+ if let Some(prev) = prev {
+ self.selection = Some(prev);
+ self.list_state.scroll_to_reveal_item(prev);
+ cx.notify();
+ }
+ }
+
+ fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) {
+ if let Some(first) = self.find_next_selectable(0) {
+ self.selection = Some(first);
+ self.list_state.scroll_to_reveal_item(first);
+ cx.notify();
+ }
+ }
+
+ fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) {
+ let last = self.items.len().saturating_sub(1);
+ if let Some(last) = self.find_previous_selectable(last) {
+ self.selection = Some(last);
+ self.list_state.scroll_to_reveal_item(last);
+ cx.notify();
+ }
+ }
+
+ fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) {
+ let Some(ix) = self.selection else { return };
+ let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else {
+ return;
+ };
+ self.open_thread(session.clone(), window, cx);
+ }
+
+ fn render_list_entry(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let Some(item) = self.items.get(ix) else {
+ return div().into_any_element();
+ };
+
+ match item {
+ ArchiveListItem::BucketSeparator(bucket) => div()
+ .w_full()
+ .px_2()
+ .pt_3()
+ .pb_1()
+ .child(
+ Label::new(bucket.label())
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ ArchiveListItem::Entry {
+ session,
+ highlight_positions,
+ } => {
+ let is_selected = self.selection == Some(ix);
+ let title: SharedString =
+ session.title.clone().unwrap_or_else(|| "Untitled".into());
+ let session_info = session.clone();
+ let highlight_positions = highlight_positions.clone();
+
+ let timestamp = session.created_at.or(session.updated_at).map(|entry_time| {
+ let now = Utc::now();
+ let duration = now.signed_duration_since(entry_time);
+
+ let minutes = duration.num_minutes();
+ let hours = duration.num_hours();
+ let days = duration.num_days();
+ let weeks = days / 7;
+ let months = days / 30;
+
+ if minutes < 60 {
+ format!("{}m", minutes.max(1))
+ } else if hours < 24 {
+ format!("{}h", hours)
+ } else if weeks < 4 {
+ format!("{}w", weeks.max(1))
+ } else {
+ format!("{}mo", months.max(1))
+ }
+ });
+
+ let id = SharedString::from(format!("archive-entry-{}", ix));
+
+ let title_label = if highlight_positions.is_empty() {
+ Label::new(title)
+ .size(LabelSize::Small)
+ .truncate()
+ .into_any_element()
+ } else {
+ HighlightedLabel::new(title, highlight_positions)
+ .size(LabelSize::Small)
+ .truncate()
+ .into_any_element()
+ };
+
+ ListItem::new(id)
+ .toggle_state(is_selected)
+ .disabled(true)
+ .child(
+ h_flex()
+ .min_w_0()
+ .w_full()
+ .py_1()
+ .pl_0p5()
+ .pr_1p5()
+ .gap_2()
+ .justify_between()
+ .child(title_label)
+ .when_some(timestamp, |this, ts| {
+ this.child(
+ Label::new(ts).size(LabelSize::Small).color(Color::Muted),
+ )
+ }),
+ )
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.open_thread(session_info.clone(), window, cx);
+ }))
+ .into_any_element()
+ }
+ }
+ }
+
+ fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu {
+ let agent_server_store = self.agent_server_store.clone();
+
+ let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() {
+ (IconName::ChevronUp, Color::Accent)
+ } else {
+ (IconName::ChevronDown, Color::Muted)
+ };
+
+ let selected_agent_icon = if let Agent::Custom { name } = &self.selected_agent {
+ let store = agent_server_store.read(cx);
+ let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
+
+ if let Some(icon) = icon {
+ Icon::from_external_svg(icon)
+ } else {
+ Icon::new(IconName::Sparkle)
+ }
+ .color(Color::Muted)
+ .size(IconSize::Small)
+ } else {
+ Icon::new(IconName::ZedAgent)
+ .color(Color::Muted)
+ .size(IconSize::Small)
+ };
+
+ let this = cx.weak_entity();
+
+ PopoverMenu::new("agent_history_menu")
+ .trigger(
+ ButtonLike::new("selected_agent")
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .child(
+ h_flex().gap_1().child(selected_agent_icon).child(
+ Icon::new(chevron_icon)
+ .color(icon_color)
+ .size(IconSize::XSmall),
+ ),
+ ),
+ )
+ .menu(move |window, cx| {
+ Some(ContextMenu::build(window, cx, |menu, _window, cx| {
+ menu.item(
+ ContextMenuEntry::new("Zed Agent")
+ .icon(IconName::ZedAgent)
+ .icon_color(Color::Muted)
+ .handler({
+ let this = this.clone();
+ move |_, cx| {
+ this.update(cx, |this, cx| {
+ this.set_selected_agent(Agent::NativeAgent, cx)
+ })
+ .ok();
+ }
+ }),
+ )
+ .separator()
+ .map(|mut menu| {
+ let agent_server_store = agent_server_store.read(cx);
+ let registry_store = project::AgentRegistryStore::try_global(cx);
+ let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx));
+
+ struct AgentMenuItem {
+ id: ExternalAgentServerName,
+ display_name: SharedString,
+ }
+
+ let agent_items = agent_server_store
+ .external_agents()
+ .map(|name| {
+ let display_name = agent_server_store
+ .agent_display_name(name)
+ .or_else(|| {
+ registry_store_ref
+ .as_ref()
+ .and_then(|store| store.agent(name.0.as_ref()))
+ .map(|a| a.name().clone())
+ })
+ .unwrap_or_else(|| name.0.clone());
+ AgentMenuItem {
+ id: name.clone(),
+ display_name,
+ }
+ })
+ .sorted_unstable_by_key(|e| e.display_name.to_lowercase())
+ .collect::>();
+
+ for item in &agent_items {
+ let mut entry = ContextMenuEntry::new(item.display_name.clone());
+
+ let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| {
+ registry_store_ref
+ .as_ref()
+ .and_then(|store| store.agent(item.id.0.as_str()))
+ .and_then(|a| a.icon_path().cloned())
+ });
+
+ if let Some(icon_path) = icon_path {
+ entry = entry.custom_icon_svg(icon_path);
+ } else {
+ entry = entry.icon(IconName::ZedAgent);
+ }
+
+ entry = entry.icon_color(Color::Muted).handler({
+ let this = this.clone();
+ let agent = Agent::Custom {
+ name: item.id.0.clone(),
+ };
+ move |_, cx| {
+ this.update(cx, |this, cx| {
+ this.set_selected_agent(agent.clone(), cx)
+ })
+ .ok();
+ }
+ });
+
+ menu = menu.item(entry);
+ }
+ menu
+ })
+ }))
+ })
+ .with_handle(self.selected_agent_menu.clone())
+ .anchor(gpui::Corner::TopRight)
+ .offset(gpui::Point {
+ x: px(1.0),
+ y: px(1.0),
+ })
+ }
+
+ fn render_header(&self, cx: &mut Context) -> impl IntoElement {
+ let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
+
+ h_flex()
+ .h(Tab::container_height(cx))
+ .px_1()
+ .gap_1p5()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ h_flex()
+ .flex_1()
+ .w_full()
+ .gap_1p5()
+ .child(
+ IconButton::new("back", IconName::ArrowLeft)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Back to Sidebar"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.go_back(window, cx);
+ })),
+ )
+ .child(self.filter_editor.clone())
+ .when(has_query, |this| {
+ this.border_r_1().child(
+ IconButton::new("clear_archive_filter", IconName::Close)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Clear Search"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.reset_filter_editor_text(window, cx);
+ this.update_items(cx);
+ })),
+ )
+ }),
+ )
+ .child(self.render_agent_picker(cx))
+ }
+}
+
+impl Focusable for ThreadsArchiveView {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Render for ThreadsArchiveView {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let is_empty = self.items.is_empty();
+ let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
+
+ let empty_state_container = |label: SharedString| {
+ v_flex()
+ .flex_1()
+ .justify_center()
+ .items_center()
+ .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
+ };
+
+ v_flex()
+ .key_context("ThreadsArchiveView")
+ .track_focus(&self.focus_handle)
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::editor_move_down))
+ .on_action(cx.listener(Self::editor_move_up))
+ .on_action(cx.listener(Self::select_first))
+ .on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::confirm))
+ .size_full()
+ .bg(cx.theme().colors().surface_background)
+ .child(self.render_header(cx))
+ .child(if is_empty && has_query {
+ empty_state_container("No threads match your search.".into()).into_any_element()
+ } else if is_empty {
+ empty_state_container("No archived threads yet.".into()).into_any_element()
+ } else {
+ v_flex()
+ .flex_1()
+ .overflow_hidden()
+ .child(
+ list(
+ self.list_state.clone(),
+ cx.processor(Self::render_list_entry),
+ )
+ .flex_1()
+ .size_full(),
+ )
+ .vertical_scrollbar_for(&self.list_state, window, cx)
+ .into_any_element()
+ })
+ }
+}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 94fed7f03f46e64ef0ac929e60cf6ae848145e72..17db6371114e1623280c22a23dd44e8efc6fa594 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -27,6 +27,7 @@ pub enum IconName {
AiVZero,
AiXAi,
AiZed,
+ Archive,
ArrowCircle,
ArrowDown,
ArrowDown10,