From 15e451cec839462ebfe091da7d41023b7ea8605a Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Wed, 20 Aug 2025 18:01:22 -0300
Subject: [PATCH] thread_view: Add recent history entries & adjust empty state
(#36625)
Release Notes:
- N/A
---
assets/icons/menu_alt.svg | 2 +-
assets/icons/zed_agent.svg | 34 ++--
assets/icons/zed_assistant.svg | 4 +-
crates/agent2/src/history_store.rs | 4 +
crates/agent2/src/native_agent_server.rs | 2 +-
crates/agent_servers/src/gemini.rs | 4 +-
crates/agent_ui/src/acp/thread_history.rs | 149 ++++++++++++++-
crates/agent_ui/src/acp/thread_view.rs | 198 +++++++++++++++-----
crates/agent_ui/src/ui.rs | 2 -
crates/agent_ui/src/ui/new_thread_button.rs | 75 --------
10 files changed, 325 insertions(+), 149 deletions(-)
delete mode 100644 crates/agent_ui/src/ui/new_thread_button.rs
diff --git a/assets/icons/menu_alt.svg b/assets/icons/menu_alt.svg
index 87add13216..b9cc19e22f 100644
--- a/assets/icons/menu_alt.svg
+++ b/assets/icons/menu_alt.svg
@@ -1,3 +1,3 @@
diff --git a/assets/icons/zed_agent.svg b/assets/icons/zed_agent.svg
index b6e120a0b6..0c80e22c51 100644
--- a/assets/icons/zed_agent.svg
+++ b/assets/icons/zed_agent.svg
@@ -1,27 +1,27 @@
diff --git a/assets/icons/zed_assistant.svg b/assets/icons/zed_assistant.svg
index 470eb0fede..812277a100 100644
--- a/assets/icons/zed_assistant.svg
+++ b/assets/icons/zed_assistant.svg
@@ -1,5 +1,5 @@
diff --git a/crates/agent2/src/history_store.rs b/crates/agent2/src/history_store.rs
index 870c2607c4..2d70164a66 100644
--- a/crates/agent2/src/history_store.rs
+++ b/crates/agent2/src/history_store.rs
@@ -345,4 +345,8 @@ impl HistoryStore {
.retain(|old_entry| old_entry != entry);
self.save_recently_opened_entries(cx);
}
+
+ pub fn recent_entries(&self, limit: usize, cx: &mut Context) -> Vec {
+ self.entries(cx).into_iter().take(limit).collect()
+ }
}
diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs
index 74d24efb13..a1f935589a 100644
--- a/crates/agent2/src/native_agent_server.rs
+++ b/crates/agent2/src/native_agent_server.rs
@@ -27,7 +27,7 @@ impl AgentServer for NativeAgentServer {
}
fn empty_state_headline(&self) -> &'static str {
- ""
+ "Welcome to the Agent Panel"
}
fn empty_state_message(&self) -> &'static str {
diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs
index 813f8b1fe0..dcbeaa1d63 100644
--- a/crates/agent_servers/src/gemini.rs
+++ b/crates/agent_servers/src/gemini.rs
@@ -18,11 +18,11 @@ const ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini {
fn name(&self) -> &'static str {
- "Gemini"
+ "Gemini CLI"
}
fn empty_state_headline(&self) -> &'static str {
- "Welcome to Gemini"
+ "Welcome to Gemini CLI"
}
fn empty_state_message(&self) -> &'static str {
diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs
index 8a05801139..68a41f31d0 100644
--- a/crates/agent_ui/src/acp/thread_history.rs
+++ b/crates/agent_ui/src/acp/thread_history.rs
@@ -1,11 +1,12 @@
-use crate::RemoveSelectedThread;
+use crate::acp::AcpThreadView;
+use crate::{AgentPanel, RemoveSelectedThread};
use agent2::{HistoryEntry, HistoryStore};
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
use editor::{Editor, EditorEvent};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
- UniformListScrollHandle, Window, uniform_list,
+ UniformListScrollHandle, WeakEntity, Window, uniform_list,
};
use std::{fmt::Display, ops::Range, sync::Arc};
use time::{OffsetDateTime, UtcOffset};
@@ -639,6 +640,150 @@ impl Render for AcpThreadHistory {
}
}
+#[derive(IntoElement)]
+pub struct AcpHistoryEntryElement {
+ entry: HistoryEntry,
+ thread_view: WeakEntity,
+ selected: bool,
+ hovered: bool,
+ on_hover: Box,
+}
+
+impl AcpHistoryEntryElement {
+ pub fn new(entry: HistoryEntry, thread_view: WeakEntity) -> Self {
+ Self {
+ entry,
+ thread_view,
+ selected: false,
+ hovered: false,
+ on_hover: Box::new(|_, _, _| {}),
+ }
+ }
+
+ 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 AcpHistoryEntryElement {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let (id, title, timestamp) = match &self.entry {
+ HistoryEntry::AcpThread(thread) => (
+ thread.id.to_string(),
+ thread.title.clone(),
+ thread.updated_at,
+ ),
+ HistoryEntry::TextThread(context) => (
+ context.path.to_string_lossy().to_string(),
+ context.title.clone(),
+ context.mtime.to_utc(),
+ ),
+ };
+
+ let formatted_time = {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(timestamp);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h ago", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m ago", duration.num_minutes())
+ } else {
+ "Just now".to_string()
+ }
+ };
+
+ ListItem::new(SharedString::from(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::(if self.hovered || self.selected {
+ 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, window, cx)
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let entry = self.entry.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(entry.clone(), 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())
+ {
+ match &entry {
+ HistoryEntry::AcpThread(thread_metadata) => {
+ if let Some(panel) = workspace.read(cx).panel::(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(
+ thread_metadata.clone(),
+ window,
+ cx,
+ );
+ });
+ }
+ }
+ HistoryEntry::TextThread(context) => {
+ if let Some(panel) = workspace.read(cx).panel::(cx) {
+ panel.update(cx, |panel, cx| {
+ panel
+ .open_saved_prompt_editor(
+ context.path.clone(),
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ });
+ }
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
#[derive(Clone, Copy)]
pub enum EntryTimeFormat {
DateAndTime,
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 2b87144fcd..35da9b8c85 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -8,7 +8,7 @@ use action_log::ActionLog;
use agent_client_protocol::{self as acp};
use agent_servers::{AgentServer, ClaudeCode};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
-use agent2::{DbThreadMetadata, HistoryEntryId, HistoryStore};
+use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
use anyhow::bail;
use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
@@ -54,11 +54,12 @@ use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
use crate::agent_diff::AgentDiff;
use crate::profile_selector::{ProfileProvider, ProfileSelector};
+
use crate::ui::preview::UsageCallout;
use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip};
use crate::{
AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
- KeepAll, OpenAgentDiff, RejectAll, ToggleBurnMode, ToggleProfileSelector,
+ KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
};
const RESPONSE_PADDING_X: Pixels = px(19.);
@@ -240,6 +241,7 @@ pub struct AcpThreadView {
project: Entity,
thread_state: ThreadState,
history_store: Entity,
+ hovered_recent_history_item: Option,
entry_view_state: Entity,
message_editor: Entity,
model_selector: Option>,
@@ -357,6 +359,7 @@ impl AcpThreadView {
editor_expanded: false,
terminal_expanded: true,
history_store,
+ hovered_recent_history_item: None,
_subscriptions: subscriptions,
_cancel_task: None,
}
@@ -582,6 +585,10 @@ impl AcpThreadView {
cx.notify();
}
+ pub fn workspace(&self) -> &WeakEntity {
+ &self.workspace
+ }
+
pub fn thread(&self) -> Option<&Entity> {
match &self.thread_state {
ThreadState::Ready { thread, .. } => Some(thread),
@@ -2284,51 +2291,132 @@ impl AcpThreadView {
)
}
- fn render_empty_state(&self, cx: &App) -> AnyElement {
+ fn render_empty_state_section_header(
+ &self,
+ label: impl Into,
+ action_slot: Option,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ div().pl_1().pr_1p5().child(
+ h_flex()
+ .mt_2()
+ .pl_1p5()
+ .pb_1()
+ .w_full()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(
+ Label::new(label.into())
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .children(action_slot),
+ )
+ }
+
+ fn render_empty_state(&self, window: &mut Window, cx: &mut Context) -> AnyElement {
let loading = matches!(&self.thread_state, ThreadState::Loading { .. });
+ let recent_history = self
+ .history_store
+ .update(cx, |history_store, cx| history_store.recent_entries(3, cx));
+ let no_history = self
+ .history_store
+ .update(cx, |history_store, cx| history_store.is_empty(cx));
v_flex()
.size_full()
- .items_center()
- .justify_center()
- .child(if loading {
- h_flex()
- .justify_center()
- .child(self.render_agent_logo())
- .with_animation(
- "pulsating_icon",
- Animation::new(Duration::from_secs(2))
- .repeat()
- .with_easing(pulsating_between(0.4, 1.0)),
- |icon, delta| icon.opacity(delta),
- )
- .into_any()
- } else {
- self.render_agent_logo().into_any_element()
- })
- .child(h_flex().mt_4().mb_1().justify_center().child(if loading {
- div()
- .child(LoadingLabel::new("").size(LabelSize::Large))
- .into_any_element()
- } else {
- Headline::new(self.agent.empty_state_headline())
- .size(HeadlineSize::Medium)
- .into_any_element()
- }))
- .child(
- div()
- .max_w_1_2()
- .text_sm()
- .text_center()
- .map(|this| {
- if loading {
- this.invisible()
+ .when(no_history, |this| {
+ this.child(
+ v_flex()
+ .size_full()
+ .items_center()
+ .justify_center()
+ .child(if loading {
+ h_flex()
+ .justify_center()
+ .child(self.render_agent_logo())
+ .with_animation(
+ "pulsating_icon",
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 1.0)),
+ |icon, delta| icon.opacity(delta),
+ )
+ .into_any()
} else {
- this.text_color(cx.theme().colors().text_muted)
- }
- })
- .child(self.agent.empty_state_message()),
- )
+ self.render_agent_logo().into_any_element()
+ })
+ .child(h_flex().mt_4().mb_2().justify_center().child(if loading {
+ div()
+ .child(LoadingLabel::new("").size(LabelSize::Large))
+ .into_any_element()
+ } else {
+ Headline::new(self.agent.empty_state_headline())
+ .size(HeadlineSize::Medium)
+ .into_any_element()
+ })),
+ )
+ })
+ .when(!no_history, |this| {
+ this.justify_end().child(
+ v_flex()
+ .child(
+ self.render_empty_state_section_header(
+ "Recent",
+ Some(
+ Button::new("view-history", "View All")
+ .style(ButtonStyle::Subtle)
+ .label_size(LabelSize::Small)
+ .key_binding(
+ KeyBinding::for_action_in(
+ &OpenHistory,
+ &self.focus_handle(cx),
+ window,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(move |_event, window, cx| {
+ window.dispatch_action(OpenHistory.boxed_clone(), cx);
+ })
+ .into_any_element(),
+ ),
+ cx,
+ ),
+ )
+ .child(
+ v_flex().p_1().pr_1p5().gap_1().children(
+ recent_history
+ .into_iter()
+ .enumerate()
+ .map(|(index, entry)| {
+ // TODO: Add keyboard navigation.
+ let is_hovered =
+ self.hovered_recent_history_item == Some(index);
+ crate::acp::thread_history::AcpHistoryEntryElement::new(
+ entry,
+ cx.entity().downgrade(),
+ )
+ .hovered(is_hovered)
+ .on_hover(cx.listener(
+ move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_recent_history_item = Some(index);
+ } else if this.hovered_recent_history_item
+ == Some(index)
+ {
+ this.hovered_recent_history_item = None;
+ }
+ cx.notify();
+ },
+ ))
+ .into_any_element()
+ }),
+ ),
+ ),
+ )
+ })
.into_any()
}
@@ -2351,9 +2439,11 @@ impl AcpThreadView {
.items_center()
.justify_center()
.child(self.render_error_agent_logo())
- .child(h_flex().mt_4().mb_1().justify_center().child(
- Headline::new(self.agent.empty_state_headline()).size(HeadlineSize::Medium),
- ))
+ .child(
+ h_flex().mt_4().mb_1().justify_center().child(
+ Headline::new("Authentication Required").size(HeadlineSize::Medium),
+ ),
+ )
.into_any(),
)
.children(description.map(|desc| {
@@ -4234,6 +4324,18 @@ impl AcpThreadView {
);
cx.notify();
}
+
+ pub fn delete_history_entry(&mut self, entry: HistoryEntry, cx: &mut Context) {
+ let task = match entry {
+ HistoryEntry::AcpThread(thread) => self.history_store.update(cx, |history, cx| {
+ history.delete_thread(thread.id.clone(), cx)
+ }),
+ HistoryEntry::TextThread(context) => self.history_store.update(cx, |history, cx| {
+ history.delete_text_thread(context.path.clone(), cx)
+ }),
+ };
+ task.detach_and_log_err(cx);
+ }
}
impl Focusable for AcpThreadView {
@@ -4268,7 +4370,9 @@ impl Render for AcpThreadView {
window,
cx,
),
- ThreadState::Loading { .. } => v_flex().flex_1().child(self.render_empty_state(cx)),
+ ThreadState::Loading { .. } => {
+ v_flex().flex_1().child(self.render_empty_state(window, cx))
+ }
ThreadState::LoadError(e) => v_flex()
.p_2()
.flex_1()
@@ -4310,7 +4414,7 @@ impl Render for AcpThreadView {
},
)
} else {
- this.child(self.render_empty_state(cx))
+ this.child(self.render_empty_state(window, cx))
}
})
}
diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs
index beeaf0c43b..e27a224240 100644
--- a/crates/agent_ui/src/ui.rs
+++ b/crates/agent_ui/src/ui.rs
@@ -2,7 +2,6 @@ mod agent_notification;
mod burn_mode_tooltip;
mod context_pill;
mod end_trial_upsell;
-// mod new_thread_button;
mod onboarding_modal;
pub mod preview;
@@ -10,5 +9,4 @@ pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
-// pub use new_thread_button::*;
pub use onboarding_modal::*;
diff --git a/crates/agent_ui/src/ui/new_thread_button.rs b/crates/agent_ui/src/ui/new_thread_button.rs
deleted file mode 100644
index 347d6adcaf..0000000000
--- a/crates/agent_ui/src/ui/new_thread_button.rs
+++ /dev/null
@@ -1,75 +0,0 @@
-use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled};
-use ui::prelude::*;
-
-#[derive(IntoElement)]
-pub struct NewThreadButton {
- id: ElementId,
- label: SharedString,
- icon: IconName,
- keybinding: Option,
- on_click: Option>,
-}
-
-impl NewThreadButton {
- fn new(id: impl Into, label: impl Into, icon: IconName) -> Self {
- Self {
- id: id.into(),
- label: label.into(),
- icon,
- keybinding: None,
- on_click: None,
- }
- }
-
- fn keybinding(mut self, keybinding: Option) -> Self {
- self.keybinding = keybinding;
- self
- }
-
- fn on_click(mut self, handler: F) -> Self
- where
- F: Fn(&mut Window, &mut App) + 'static,
- {
- self.on_click = Some(Box::new(
- move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx),
- ));
- self
- }
-}
-
-impl RenderOnce for NewThreadButton {
- fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- h_flex()
- .id(self.id)
- .w_full()
- .py_1p5()
- .px_2()
- .gap_1()
- .justify_between()
- .rounded_md()
- .border_1()
- .border_color(cx.theme().colors().border.opacity(0.4))
- .bg(cx.theme().colors().element_active.opacity(0.2))
- .hover(|style| {
- style
- .bg(cx.theme().colors().element_hover)
- .border_color(cx.theme().colors().border)
- })
- .child(
- h_flex()
- .gap_1p5()
- .child(
- Icon::new(self.icon)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- )
- .child(Label::new(self.label).size(LabelSize::Small)),
- )
- .when_some(self.keybinding, |this, keybinding| {
- this.child(keybinding.size(rems_from_px(10.)))
- })
- .when_some(self.on_click, |this, on_click| {
- this.on_click(move |event, window, cx| on_click(event, window, cx))
- })
- }
-}