From 938e28f871986c747e6a60f40369860739a5ce95 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 17 Jan 2025 18:41:17 -0300 Subject: [PATCH] assistant2: Thread history keyboard navigation (#23145) Open and delete threads via keyboard: https://github.com/user-attachments/assets/79b402ad-a49d-4c52-9d46-28a7bf32ff1f Note: this doesn't include navigation in the "recent threads" section of the empty state Release Notes: - N/A --- assets/keymaps/default-linux.json | 6 ++ assets/keymaps/default-macos.json | 6 ++ crates/assistant2/src/assistant.rs | 1 + crates/assistant2/src/assistant_panel.rs | 13 ++- crates/assistant2/src/thread_history.rs | 103 ++++++++++++++++++++++- crates/assistant2/src/thread_store.rs | 8 ++ 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index c29a961841a1053b504ef271f82652ab207b4912..b3bf895c5d1c32c2a1c163d9bb3b6e739dd301df 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -597,6 +597,12 @@ "enter": "assistant2::AcceptSuggestedContext" } }, + { + "context": "ThreadHistory", + "bindings": { + "backspace": "assistant2::RemoveSelectedThread" + } + }, { "context": "PromptEditor", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 627dc1095637a297426ba2ef762fd70d7d85536e..e768899ec16be90a8e205f93291184e7a189a4b6 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -252,6 +252,12 @@ "enter": "assistant2::AcceptSuggestedContext" } }, + { + "context": "ThreadHistory", + "bindings": { + "backspace": "assistant2::RemoveSelectedThread" + } + }, { "context": "PromptLibrary", "use_key_equivalents": true, diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 28883f37cf785440a5c1b1d68a5ba88c3535ee9f..bf35d35765eb04b244e25b8a50cf32fc86692ff6 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -40,6 +40,7 @@ actions!( ToggleModelSelector, RemoveAllContext, OpenHistory, + RemoveSelectedThread, Chat, ChatMode, CycleNextInlineAssist, diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 4e704be5917c408ecb4fd5c268e202eea82f84b5..0d4e5a8d54991f4cb4b561eade6ac94a2e7f8ff6 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -428,13 +428,12 @@ impl AssistantPanel { .color(Color::Muted), ), ) - .child( - v_flex().mx_auto().w_4_5().gap_2().children( - recent_threads - .into_iter() - .map(|thread| PastThread::new(thread, cx.view().downgrade())), - ), - ) + .child(v_flex().mx_auto().w_4_5().gap_2().children( + recent_threads.into_iter().map(|thread| { + // TODO: keyboard navigation + PastThread::new(thread, cx.view().downgrade(), false) + }), + )) .child( h_flex().w_full().justify_center().child( Button::new("view-all-past-threads", "View All Past Threads") diff --git a/crates/assistant2/src/thread_history.rs b/crates/assistant2/src/thread_history.rs index 0b33340d12a4f4767a2050f83c51663faab1a9d9..18619fd0514b79df7a1a04e42179766654487926 100644 --- a/crates/assistant2/src/thread_history.rs +++ b/crates/assistant2/src/thread_history.rs @@ -1,18 +1,20 @@ use gpui::{ - uniform_list, AppContext, FocusHandle, FocusableView, Model, UniformListScrollHandle, WeakView, + uniform_list, AppContext, FocusHandle, FocusableView, Model, ScrollStrategy, + UniformListScrollHandle, WeakView, }; use time::{OffsetDateTime, UtcOffset}; use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing, Tooltip}; use crate::thread::Thread; use crate::thread_store::ThreadStore; -use crate::AssistantPanel; +use crate::{AssistantPanel, RemoveSelectedThread}; pub struct ThreadHistory { focus_handle: FocusHandle, assistant_panel: WeakView, thread_store: Model, scroll_handle: UniformListScrollHandle, + selected_index: usize, } impl ThreadHistory { @@ -26,6 +28,82 @@ impl ThreadHistory { assistant_panel, thread_store, scroll_handle: UniformListScrollHandle::default(), + selected_index: 0, + } + } + + pub fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext) { + let count = self.thread_store.read(cx).non_empty_len(cx); + + if count > 0 { + if self.selected_index == 0 { + self.set_selected_index(count - 1, cx); + } else { + self.set_selected_index(self.selected_index - 1, cx); + } + } + } + + pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { + let count = self.thread_store.read(cx).non_empty_len(cx); + + if count > 0 { + if self.selected_index == count - 1 { + self.set_selected_index(0, cx); + } else { + self.set_selected_index(self.selected_index + 1, cx); + } + } + } + + fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext) { + let count = self.thread_store.read(cx).non_empty_len(cx); + if count > 0 { + self.set_selected_index(0, cx); + } + } + + fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext) { + let count = self.thread_store.read(cx).non_empty_len(cx); + if count > 0 { + self.set_selected_index(count - 1, cx); + } + } + + fn set_selected_index(&mut self, index: usize, cx: &mut ViewContext) { + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); + + if let Some(thread) = threads.get(self.selected_index) { + self.assistant_panel + .update(cx, move |this, cx| { + let thread_id = thread.read(cx).id().clone(); + this.open_thread(&thread_id, cx) + }) + .ok(); + + cx.notify(); + } + } + + fn remove_selected_thread(&mut self, _: &RemoveSelectedThread, cx: &mut ViewContext) { + let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); + + if let Some(thread) = threads.get(self.selected_index) { + self.assistant_panel + .update(cx, |this, cx| { + let thread_id = thread.read(cx).id().clone(); + this.delete_thread(&thread_id, cx); + }) + .ok(); + + cx.notify(); } } } @@ -39,13 +117,21 @@ impl FocusableView for ThreadHistory { impl Render for ThreadHistory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); + let selected_index = self.selected_index; v_flex() .id("thread-history-container") + .key_context("ThreadHistory") .track_focus(&self.focus_handle) .overflow_y_scroll() .size_full() .p_1() + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) .map(|history| { if threads.is_empty() { history @@ -65,10 +151,12 @@ impl Render for ThreadHistory { move |history, range, _cx| { threads[range] .iter() - .map(|thread| { + .enumerate() + .map(|(index, thread)| { h_flex().w_full().pb_1().child(PastThread::new( thread.clone(), history.assistant_panel.clone(), + selected_index == index, )) }) .collect() @@ -86,13 +174,19 @@ impl Render for ThreadHistory { pub struct PastThread { thread: Model, assistant_panel: WeakView, + selected: bool, } impl PastThread { - pub fn new(thread: Model, assistant_panel: WeakView) -> Self { + pub fn new( + thread: Model, + assistant_panel: WeakView, + selected: bool, + ) -> Self { Self { thread, assistant_panel, + selected, } } } @@ -116,6 +210,7 @@ impl RenderOnce for PastThread { ListItem::new(("past-thread", self.thread.entity_id())) .outlined() + .toggle_state(self.selected) .start_slot( Icon::new(IconName::MessageCircle) .size(IconSize::Small) diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs index 367a60a3665087b0cbadc62a9ac69b7033c426c0..e07e447f79e2b0a601105c1aa836f8f5cc279bf5 100644 --- a/crates/assistant2/src/thread_store.rs +++ b/crates/assistant2/src/thread_store.rs @@ -52,6 +52,14 @@ impl ThreadStore { }) } + /// Returns the number of non-empty threads. + pub fn non_empty_len(&self, cx: &AppContext) -> usize { + self.threads + .iter() + .filter(|thread| !thread.read(cx).is_empty()) + .count() + } + pub fn threads(&self, cx: &ModelContext) -> Vec> { let mut threads = self .threads