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
This commit is contained in:
Agus Zubiaga 2025-01-17 18:41:17 -03:00 committed by GitHub
parent 5da67899b7
commit 938e28f871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 126 additions and 11 deletions

View file

@ -597,6 +597,12 @@
"enter": "assistant2::AcceptSuggestedContext" "enter": "assistant2::AcceptSuggestedContext"
} }
}, },
{
"context": "ThreadHistory",
"bindings": {
"backspace": "assistant2::RemoveSelectedThread"
}
},
{ {
"context": "PromptEditor", "context": "PromptEditor",
"bindings": { "bindings": {

View file

@ -252,6 +252,12 @@
"enter": "assistant2::AcceptSuggestedContext" "enter": "assistant2::AcceptSuggestedContext"
} }
}, },
{
"context": "ThreadHistory",
"bindings": {
"backspace": "assistant2::RemoveSelectedThread"
}
},
{ {
"context": "PromptLibrary", "context": "PromptLibrary",
"use_key_equivalents": true, "use_key_equivalents": true,

View file

@ -40,6 +40,7 @@ actions!(
ToggleModelSelector, ToggleModelSelector,
RemoveAllContext, RemoveAllContext,
OpenHistory, OpenHistory,
RemoveSelectedThread,
Chat, Chat,
ChatMode, ChatMode,
CycleNextInlineAssist, CycleNextInlineAssist,

View file

@ -428,13 +428,12 @@ impl AssistantPanel {
.color(Color::Muted), .color(Color::Muted),
), ),
) )
.child( .child(v_flex().mx_auto().w_4_5().gap_2().children(
v_flex().mx_auto().w_4_5().gap_2().children( recent_threads.into_iter().map(|thread| {
recent_threads // TODO: keyboard navigation
.into_iter() PastThread::new(thread, cx.view().downgrade(), false)
.map(|thread| PastThread::new(thread, cx.view().downgrade())), }),
), ))
)
.child( .child(
h_flex().w_full().justify_center().child( h_flex().w_full().justify_center().child(
Button::new("view-all-past-threads", "View All Past Threads") Button::new("view-all-past-threads", "View All Past Threads")

View file

@ -1,18 +1,20 @@
use gpui::{ use gpui::{
uniform_list, AppContext, FocusHandle, FocusableView, Model, UniformListScrollHandle, WeakView, uniform_list, AppContext, FocusHandle, FocusableView, Model, ScrollStrategy,
UniformListScrollHandle, WeakView,
}; };
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing, Tooltip}; use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing, Tooltip};
use crate::thread::Thread; use crate::thread::Thread;
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::AssistantPanel; use crate::{AssistantPanel, RemoveSelectedThread};
pub struct ThreadHistory { pub struct ThreadHistory {
focus_handle: FocusHandle, focus_handle: FocusHandle,
assistant_panel: WeakView<AssistantPanel>, assistant_panel: WeakView<AssistantPanel>,
thread_store: Model<ThreadStore>, thread_store: Model<ThreadStore>,
scroll_handle: UniformListScrollHandle, scroll_handle: UniformListScrollHandle,
selected_index: usize,
} }
impl ThreadHistory { impl ThreadHistory {
@ -26,6 +28,82 @@ impl ThreadHistory {
assistant_panel, assistant_panel,
thread_store, thread_store,
scroll_handle: UniformListScrollHandle::default(), scroll_handle: UniformListScrollHandle::default(),
selected_index: 0,
}
}
pub fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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>) {
self.selected_index = index;
self.scroll_handle
.scroll_to_item(index, ScrollStrategy::Top);
cx.notify();
}
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
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<Self>) {
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 { impl Render for ThreadHistory {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); let threads = self.thread_store.update(cx, |this, cx| this.threads(cx));
let selected_index = self.selected_index;
v_flex() v_flex()
.id("thread-history-container") .id("thread-history-container")
.key_context("ThreadHistory")
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.overflow_y_scroll() .overflow_y_scroll()
.size_full() .size_full()
.p_1() .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| { .map(|history| {
if threads.is_empty() { if threads.is_empty() {
history history
@ -65,10 +151,12 @@ impl Render for ThreadHistory {
move |history, range, _cx| { move |history, range, _cx| {
threads[range] threads[range]
.iter() .iter()
.map(|thread| { .enumerate()
.map(|(index, thread)| {
h_flex().w_full().pb_1().child(PastThread::new( h_flex().w_full().pb_1().child(PastThread::new(
thread.clone(), thread.clone(),
history.assistant_panel.clone(), history.assistant_panel.clone(),
selected_index == index,
)) ))
}) })
.collect() .collect()
@ -86,13 +174,19 @@ impl Render for ThreadHistory {
pub struct PastThread { pub struct PastThread {
thread: Model<Thread>, thread: Model<Thread>,
assistant_panel: WeakView<AssistantPanel>, assistant_panel: WeakView<AssistantPanel>,
selected: bool,
} }
impl PastThread { impl PastThread {
pub fn new(thread: Model<Thread>, assistant_panel: WeakView<AssistantPanel>) -> Self { pub fn new(
thread: Model<Thread>,
assistant_panel: WeakView<AssistantPanel>,
selected: bool,
) -> Self {
Self { Self {
thread, thread,
assistant_panel, assistant_panel,
selected,
} }
} }
} }
@ -116,6 +210,7 @@ impl RenderOnce for PastThread {
ListItem::new(("past-thread", self.thread.entity_id())) ListItem::new(("past-thread", self.thread.entity_id()))
.outlined() .outlined()
.toggle_state(self.selected)
.start_slot( .start_slot(
Icon::new(IconName::MessageCircle) Icon::new(IconName::MessageCircle)
.size(IconSize::Small) .size(IconSize::Small)

View file

@ -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<Self>) -> Vec<Model<Thread>> { pub fn threads(&self, cx: &ModelContext<Self>) -> Vec<Model<Thread>> {
let mut threads = self let mut threads = self
.threads .threads