1use assistant_context_editor::SavedContextMetadata;
2use gpui::{
3 uniform_list, App, Entity, FocusHandle, Focusable, ScrollStrategy, UniformListScrollHandle,
4 WeakEntity,
5};
6use time::{OffsetDateTime, UtcOffset};
7use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing, Tooltip};
8
9use crate::history_store::{HistoryEntry, HistoryStore};
10use crate::thread_store::SavedThreadMetadata;
11use crate::{AssistantPanel, RemoveSelectedThread};
12
13pub struct ThreadHistory {
14 focus_handle: FocusHandle,
15 assistant_panel: WeakEntity<AssistantPanel>,
16 history_store: Entity<HistoryStore>,
17 scroll_handle: UniformListScrollHandle,
18 selected_index: usize,
19}
20
21impl ThreadHistory {
22 pub(crate) fn new(
23 assistant_panel: WeakEntity<AssistantPanel>,
24 history_store: Entity<HistoryStore>,
25 cx: &mut Context<Self>,
26 ) -> Self {
27 Self {
28 focus_handle: cx.focus_handle(),
29 assistant_panel,
30 history_store,
31 scroll_handle: UniformListScrollHandle::default(),
32 selected_index: 0,
33 }
34 }
35
36 pub fn select_prev(
37 &mut self,
38 _: &menu::SelectPrev,
39 window: &mut Window,
40 cx: &mut Context<Self>,
41 ) {
42 let count = self
43 .history_store
44 .update(cx, |this, cx| this.entry_count(cx));
45 if count > 0 {
46 if self.selected_index == 0 {
47 self.set_selected_index(count - 1, window, cx);
48 } else {
49 self.set_selected_index(self.selected_index - 1, window, cx);
50 }
51 }
52 }
53
54 pub fn select_next(
55 &mut self,
56 _: &menu::SelectNext,
57 window: &mut Window,
58 cx: &mut Context<Self>,
59 ) {
60 let count = self
61 .history_store
62 .update(cx, |this, cx| this.entry_count(cx));
63 if count > 0 {
64 if self.selected_index == count - 1 {
65 self.set_selected_index(0, window, cx);
66 } else {
67 self.set_selected_index(self.selected_index + 1, window, cx);
68 }
69 }
70 }
71
72 fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
73 let count = self
74 .history_store
75 .update(cx, |this, cx| this.entry_count(cx));
76 if count > 0 {
77 self.set_selected_index(0, window, cx);
78 }
79 }
80
81 fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
82 let count = self
83 .history_store
84 .update(cx, |this, cx| this.entry_count(cx));
85 if count > 0 {
86 self.set_selected_index(count - 1, window, cx);
87 }
88 }
89
90 fn set_selected_index(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
91 self.selected_index = index;
92 self.scroll_handle
93 .scroll_to_item(index, ScrollStrategy::Top);
94 cx.notify();
95 }
96
97 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
98 let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
99
100 if let Some(entry) = entries.get(self.selected_index) {
101 match entry {
102 HistoryEntry::Thread(thread) => {
103 self.assistant_panel
104 .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx))
105 .ok();
106 }
107 HistoryEntry::Context(context) => {
108 self.assistant_panel
109 .update(cx, move |this, cx| {
110 this.open_saved_prompt_editor(context.path.clone(), window, cx)
111 })
112 .ok();
113 }
114 }
115
116 cx.notify();
117 }
118 }
119
120 fn remove_selected_thread(
121 &mut self,
122 _: &RemoveSelectedThread,
123 _window: &mut Window,
124 cx: &mut Context<Self>,
125 ) {
126 let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
127
128 if let Some(entry) = entries.get(self.selected_index) {
129 match entry {
130 HistoryEntry::Thread(thread) => {
131 self.assistant_panel
132 .update(cx, |this, cx| {
133 this.delete_thread(&thread.id, cx);
134 })
135 .ok();
136 }
137 HistoryEntry::Context(_context) => {}
138 }
139
140 cx.notify();
141 }
142 }
143}
144
145impl Focusable for ThreadHistory {
146 fn focus_handle(&self, _cx: &App) -> FocusHandle {
147 self.focus_handle.clone()
148 }
149}
150
151impl Render for ThreadHistory {
152 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
153 let history_entries = self.history_store.update(cx, |this, cx| this.entries(cx));
154 let selected_index = self.selected_index;
155
156 v_flex()
157 .id("thread-history-container")
158 .key_context("ThreadHistory")
159 .track_focus(&self.focus_handle)
160 .overflow_y_scroll()
161 .size_full()
162 .p_1()
163 .on_action(cx.listener(Self::select_prev))
164 .on_action(cx.listener(Self::select_next))
165 .on_action(cx.listener(Self::select_first))
166 .on_action(cx.listener(Self::select_last))
167 .on_action(cx.listener(Self::confirm))
168 .on_action(cx.listener(Self::remove_selected_thread))
169 .map(|history| {
170 if history_entries.is_empty() {
171 history
172 .justify_center()
173 .child(
174 h_flex().w_full().justify_center().child(
175 Label::new("You don't have any past threads yet.")
176 .size(LabelSize::Small),
177 ),
178 )
179 } else {
180 history.child(
181 uniform_list(
182 cx.entity().clone(),
183 "thread-history",
184 history_entries.len(),
185 move |history, range, _window, _cx| {
186 history_entries[range]
187 .iter()
188 .enumerate()
189 .map(|(index, entry)| {
190 h_flex().w_full().pb_1().child(match entry {
191 HistoryEntry::Thread(thread) => PastThread::new(
192 thread.clone(),
193 history.assistant_panel.clone(),
194 selected_index == index,
195 )
196 .into_any_element(),
197 HistoryEntry::Context(context) => PastContext::new(
198 context.clone(),
199 history.assistant_panel.clone(),
200 selected_index == index,
201 )
202 .into_any_element(),
203 })
204 })
205 .collect()
206 },
207 )
208 .track_scroll(self.scroll_handle.clone())
209 .flex_grow(),
210 )
211 }
212 })
213 }
214}
215
216#[derive(IntoElement)]
217pub struct PastThread {
218 thread: SavedThreadMetadata,
219 assistant_panel: WeakEntity<AssistantPanel>,
220 selected: bool,
221}
222
223impl PastThread {
224 pub fn new(
225 thread: SavedThreadMetadata,
226 assistant_panel: WeakEntity<AssistantPanel>,
227 selected: bool,
228 ) -> Self {
229 Self {
230 thread,
231 assistant_panel,
232 selected,
233 }
234 }
235}
236
237impl RenderOnce for PastThread {
238 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
239 let summary = self.thread.summary;
240
241 let thread_timestamp = time_format::format_localized_timestamp(
242 OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
243 OffsetDateTime::now_utc(),
244 self.assistant_panel
245 .update(cx, |this, _cx| this.local_timezone())
246 .unwrap_or(UtcOffset::UTC),
247 time_format::TimestampFormat::EnhancedAbsolute,
248 );
249
250 ListItem::new(SharedString::from(self.thread.id.to_string()))
251 .outlined()
252 .toggle_state(self.selected)
253 .start_slot(
254 Icon::new(IconName::MessageCircle)
255 .size(IconSize::Small)
256 .color(Color::Muted),
257 )
258 .spacing(ListItemSpacing::Sparse)
259 .child(Label::new(summary).size(LabelSize::Small).text_ellipsis())
260 .end_slot(
261 h_flex()
262 .gap_1p5()
263 .child(
264 Label::new(thread_timestamp)
265 .color(Color::Muted)
266 .size(LabelSize::XSmall),
267 )
268 .child(
269 IconButton::new("delete", IconName::TrashAlt)
270 .shape(IconButtonShape::Square)
271 .icon_size(IconSize::XSmall)
272 .tooltip(Tooltip::text("Delete Thread"))
273 .on_click({
274 let assistant_panel = self.assistant_panel.clone();
275 let id = self.thread.id.clone();
276 move |_event, _window, cx| {
277 assistant_panel
278 .update(cx, |this, cx| {
279 this.delete_thread(&id, cx);
280 })
281 .ok();
282 }
283 }),
284 ),
285 )
286 .on_click({
287 let assistant_panel = self.assistant_panel.clone();
288 let id = self.thread.id.clone();
289 move |_event, window, cx| {
290 assistant_panel
291 .update(cx, |this, cx| {
292 this.open_thread(&id, window, cx).detach_and_log_err(cx);
293 })
294 .ok();
295 }
296 })
297 }
298}
299
300#[derive(IntoElement)]
301pub struct PastContext {
302 context: SavedContextMetadata,
303 assistant_panel: WeakEntity<AssistantPanel>,
304 selected: bool,
305}
306
307impl PastContext {
308 pub fn new(
309 context: SavedContextMetadata,
310 assistant_panel: WeakEntity<AssistantPanel>,
311 selected: bool,
312 ) -> Self {
313 Self {
314 context,
315 assistant_panel,
316 selected,
317 }
318 }
319}
320
321impl RenderOnce for PastContext {
322 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
323 let summary = self.context.title;
324
325 let context_timestamp = time_format::format_localized_timestamp(
326 OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
327 OffsetDateTime::now_utc(),
328 self.assistant_panel
329 .update(cx, |this, _cx| this.local_timezone())
330 .unwrap_or(UtcOffset::UTC),
331 time_format::TimestampFormat::EnhancedAbsolute,
332 );
333
334 ListItem::new(SharedString::from(
335 self.context.path.to_string_lossy().to_string(),
336 ))
337 .outlined()
338 .toggle_state(self.selected)
339 .start_slot(
340 Icon::new(IconName::Code)
341 .size(IconSize::Small)
342 .color(Color::Muted),
343 )
344 .spacing(ListItemSpacing::Sparse)
345 .child(Label::new(summary).size(LabelSize::Small).text_ellipsis())
346 .end_slot(
347 h_flex().gap_1p5().child(
348 Label::new(context_timestamp)
349 .color(Color::Muted)
350 .size(LabelSize::XSmall),
351 ),
352 )
353 .on_click({
354 let assistant_panel = self.assistant_panel.clone();
355 let path = self.context.path.clone();
356 move |_event, window, cx| {
357 assistant_panel
358 .update(cx, |this, cx| {
359 this.open_saved_prompt_editor(path.clone(), window, cx)
360 .detach_and_log_err(cx);
361 })
362 .ok();
363 }
364 })
365 }
366}