1use assistant_context_editor::SavedContextMetadata;
2use gpui::{
3 App, Entity, FocusHandle, Focusable, ScrollStrategy, UniformListScrollHandle, WeakEntity,
4 uniform_list,
5};
6use time::{OffsetDateTime, UtcOffset};
7use ui::{IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*};
8
9use crate::history_store::{HistoryEntry, HistoryStore};
10use crate::thread_store::SerializedThreadMetadata;
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_previous(
37 &mut self,
38 _: &menu::SelectPrevious,
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 self.assistant_panel
139 .update(cx, |this, cx| {
140 this.delete_context(context.path.clone(), cx);
141 })
142 .ok();
143 }
144 }
145
146 cx.notify();
147 }
148 }
149}
150
151impl Focusable for ThreadHistory {
152 fn focus_handle(&self, _cx: &App) -> FocusHandle {
153 self.focus_handle.clone()
154 }
155}
156
157impl Render for ThreadHistory {
158 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
159 let history_entries = self.history_store.update(cx, |this, cx| this.entries(cx));
160 let selected_index = self.selected_index;
161
162 v_flex()
163 .id("thread-history-container")
164 .key_context("ThreadHistory")
165 .track_focus(&self.focus_handle)
166 .overflow_y_scroll()
167 .size_full()
168 .p_1()
169 .on_action(cx.listener(Self::select_previous))
170 .on_action(cx.listener(Self::select_next))
171 .on_action(cx.listener(Self::select_first))
172 .on_action(cx.listener(Self::select_last))
173 .on_action(cx.listener(Self::confirm))
174 .on_action(cx.listener(Self::remove_selected_thread))
175 .map(|history| {
176 if history_entries.is_empty() {
177 history
178 .justify_center()
179 .child(
180 h_flex().w_full().justify_center().child(
181 Label::new("You don't have any past threads yet.")
182 .size(LabelSize::Small),
183 ),
184 )
185 } else {
186 history.child(
187 uniform_list(
188 cx.entity().clone(),
189 "thread-history",
190 history_entries.len(),
191 move |history, range, _window, _cx| {
192 history_entries[range]
193 .iter()
194 .enumerate()
195 .map(|(index, entry)| {
196 h_flex().w_full().pb_1().child(match entry {
197 HistoryEntry::Thread(thread) => PastThread::new(
198 thread.clone(),
199 history.assistant_panel.clone(),
200 selected_index == index,
201 )
202 .into_any_element(),
203 HistoryEntry::Context(context) => PastContext::new(
204 context.clone(),
205 history.assistant_panel.clone(),
206 selected_index == index,
207 )
208 .into_any_element(),
209 })
210 })
211 .collect()
212 },
213 )
214 .track_scroll(self.scroll_handle.clone())
215 .flex_grow(),
216 )
217 }
218 })
219 }
220}
221
222#[derive(IntoElement)]
223pub struct PastThread {
224 thread: SerializedThreadMetadata,
225 assistant_panel: WeakEntity<AssistantPanel>,
226 selected: bool,
227}
228
229impl PastThread {
230 pub fn new(
231 thread: SerializedThreadMetadata,
232 assistant_panel: WeakEntity<AssistantPanel>,
233 selected: bool,
234 ) -> Self {
235 Self {
236 thread,
237 assistant_panel,
238 selected,
239 }
240 }
241}
242
243impl RenderOnce for PastThread {
244 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
245 let summary = self.thread.summary;
246
247 let thread_timestamp = time_format::format_localized_timestamp(
248 OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(),
249 OffsetDateTime::now_utc(),
250 self.assistant_panel
251 .update(cx, |this, _cx| this.local_timezone())
252 .unwrap_or(UtcOffset::UTC),
253 time_format::TimestampFormat::EnhancedAbsolute,
254 );
255
256 ListItem::new(SharedString::from(self.thread.id.to_string()))
257 .rounded()
258 .toggle_state(self.selected)
259 .spacing(ListItemSpacing::Sparse)
260 .start_slot(
261 div()
262 .max_w_4_5()
263 .child(Label::new(summary).size(LabelSize::Small).truncate()),
264 )
265 .end_slot(
266 h_flex()
267 .gap_1p5()
268 .child(
269 Label::new("Thread")
270 .color(Color::Muted)
271 .size(LabelSize::XSmall),
272 )
273 .child(
274 div()
275 .size(px(3.))
276 .rounded_full()
277 .bg(cx.theme().colors().text_disabled),
278 )
279 .child(
280 Label::new(thread_timestamp)
281 .color(Color::Muted)
282 .size(LabelSize::XSmall),
283 )
284 .child(
285 IconButton::new("delete", IconName::TrashAlt)
286 .shape(IconButtonShape::Square)
287 .icon_size(IconSize::XSmall)
288 .tooltip(Tooltip::text("Delete Thread"))
289 .on_click({
290 let assistant_panel = self.assistant_panel.clone();
291 let id = self.thread.id.clone();
292 move |_event, _window, cx| {
293 assistant_panel
294 .update(cx, |this, cx| {
295 this.delete_thread(&id, cx);
296 })
297 .ok();
298 }
299 }),
300 ),
301 )
302 .on_click({
303 let assistant_panel = self.assistant_panel.clone();
304 let id = self.thread.id.clone();
305 move |_event, window, cx| {
306 assistant_panel
307 .update(cx, |this, cx| {
308 this.open_thread(&id, window, cx).detach_and_log_err(cx);
309 })
310 .ok();
311 }
312 })
313 }
314}
315
316#[derive(IntoElement)]
317pub struct PastContext {
318 context: SavedContextMetadata,
319 assistant_panel: WeakEntity<AssistantPanel>,
320 selected: bool,
321}
322
323impl PastContext {
324 pub fn new(
325 context: SavedContextMetadata,
326 assistant_panel: WeakEntity<AssistantPanel>,
327 selected: bool,
328 ) -> Self {
329 Self {
330 context,
331 assistant_panel,
332 selected,
333 }
334 }
335}
336
337impl RenderOnce for PastContext {
338 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
339 let summary = self.context.title;
340
341 let context_timestamp = time_format::format_localized_timestamp(
342 OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
343 OffsetDateTime::now_utc(),
344 self.assistant_panel
345 .update(cx, |this, _cx| this.local_timezone())
346 .unwrap_or(UtcOffset::UTC),
347 time_format::TimestampFormat::EnhancedAbsolute,
348 );
349
350 ListItem::new(SharedString::from(
351 self.context.path.to_string_lossy().to_string(),
352 ))
353 .rounded()
354 .toggle_state(self.selected)
355 .spacing(ListItemSpacing::Sparse)
356 .start_slot(
357 div()
358 .max_w_4_5()
359 .child(Label::new(summary).size(LabelSize::Small).truncate()),
360 )
361 .end_slot(
362 h_flex()
363 .gap_1p5()
364 .child(
365 Label::new("Prompt Editor")
366 .color(Color::Muted)
367 .size(LabelSize::XSmall),
368 )
369 .child(
370 div()
371 .size(px(3.))
372 .rounded_full()
373 .bg(cx.theme().colors().text_disabled),
374 )
375 .child(
376 Label::new(context_timestamp)
377 .color(Color::Muted)
378 .size(LabelSize::XSmall),
379 )
380 .child(
381 IconButton::new("delete", IconName::TrashAlt)
382 .shape(IconButtonShape::Square)
383 .icon_size(IconSize::XSmall)
384 .tooltip(Tooltip::text("Delete Prompt Editor"))
385 .on_click({
386 let assistant_panel = self.assistant_panel.clone();
387 let path = self.context.path.clone();
388 move |_event, _window, cx| {
389 assistant_panel
390 .update(cx, |this, cx| {
391 this.delete_context(path.clone(), cx);
392 })
393 .ok();
394 }
395 }),
396 ),
397 )
398 .on_click({
399 let assistant_panel = self.assistant_panel.clone();
400 let path = self.context.path.clone();
401 move |_event, window, cx| {
402 assistant_panel
403 .update(cx, |this, cx| {
404 this.open_saved_prompt_editor(path.clone(), window, cx)
405 .detach_and_log_err(cx);
406 })
407 .ok();
408 }
409 })
410 }
411}