thread_view: Add recent history entries & adjust empty state (#36625)
Release Notes: - N/A
This commit is contained in:
parent
02dabbb9fa
commit
fb7edbfb46
10 changed files with 325 additions and 149 deletions
|
@ -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<AcpThreadView>,
|
||||
selected: bool,
|
||||
hovered: bool,
|
||||
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl AcpHistoryEntryElement {
|
||||
pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> 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::<IconButton>(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::<AgentPanel>(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::<AgentPanel>(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,
|
||||
|
|
|
@ -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<Project>,
|
||||
thread_state: ThreadState,
|
||||
history_store: Entity<HistoryStore>,
|
||||
hovered_recent_history_item: Option<usize>,
|
||||
entry_view_state: Entity<EntryViewState>,
|
||||
message_editor: Entity<MessageEditor>,
|
||||
model_selector: Option<Entity<AcpModelSelectorPopover>>,
|
||||
|
@ -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<Workspace> {
|
||||
&self.workspace
|
||||
}
|
||||
|
||||
pub fn thread(&self) -> Option<&Entity<AcpThread>> {
|
||||
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<SharedString>,
|
||||
action_slot: Option<AnyElement>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) -> 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<Self>) {
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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<ui::KeyBinding>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl NewThreadButton {
|
||||
fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, icon: IconName) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
icon,
|
||||
keybinding: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn keybinding(mut self, keybinding: Option<ui::KeyBinding>) -> Self {
|
||||
self.keybinding = keybinding;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_click<F>(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))
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue