From f3336b18bf808ff57c1ee486803e1f37797cfdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 07:32:48 -0400 Subject: [PATCH] T23d: AI chat, conversations - ConversationList: pinned first, then by day, newest first; a query keeps the titles that hold every word - ConversationItem: a Tab stop when there is something to do; its menu of rename, pin and delete shows on hover, while open, or while the row holds focus; a press on the menu leaves the row shut - new_chat_button, ProjectList, ProjectKnowledgePanel with a capacity meter - SharedConversationView: who shared it and when, and a way to carry it on - ConversationExport: format and thinking in a Dialog, Cancel or Export - icons: square-pen, corner-down-right - gallery: conversations with rename, pin and delete; projects; a shared conversation; export with a toast --- assets/icons/corner-down-right.svg | 16 + assets/icons/square-pen.svg | 16 + examples/gallery/pages/chat/history.rs | 344 ++++++++++++++++++++ examples/gallery/pages/chat/mod.rs | 18 +- src/chat/conversations.rs | 424 +++++++++++++++++++++++++ src/chat/mod.rs | 6 + src/chat/projects.rs | 417 ++++++++++++++++++++++++ src/chat/tests/history.rs | 98 ++++++ src/chat/tests/mod.rs | 1 + src/primitives/icon.rs | 2 + tasks/ch21-30.md | 16 +- 11 files changed, 1349 insertions(+), 9 deletions(-) create mode 100644 assets/icons/corner-down-right.svg create mode 100644 assets/icons/square-pen.svg create mode 100644 examples/gallery/pages/chat/history.rs create mode 100644 src/chat/conversations.rs create mode 100644 src/chat/projects.rs create mode 100644 src/chat/tests/history.rs diff --git a/assets/icons/corner-down-right.svg b/assets/icons/corner-down-right.svg new file mode 100644 index 0000000..3fdd007 --- /dev/null +++ b/assets/icons/corner-down-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/square-pen.svg b/assets/icons/square-pen.svg new file mode 100644 index 0000000..9580d9b --- /dev/null +++ b/assets/icons/square-pen.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/pages/chat/history.rs b/examples/gallery/pages/chat/history.rs new file mode 100644 index 0000000..3edc103 --- /dev/null +++ b/examples/gallery/pages/chat/history.rs @@ -0,0 +1,344 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + chat::{ + Conversation, ConversationExport, ConversationList, MessageAvatar, MessageBubble, + MessageList, Project, ProjectKnowledgePanel, ProjectList, Role, SharedConversationView, + StreamingMarkdown, new_chat_button, + }, + feedback::{Toast, Toaster}, + forms::{SearchInput, TextInput}, + overlays::PromptDialog, + primitives::{IconName, Severity}, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, ToSpan, tz::TimeZone}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +/// The conversations and the one open, shared by the list and the shared view. +pub struct History { + pub all: Entity>, + pub active: Entity>, +} + +pub fn history(window: &mut Window, cx: &mut App) -> History { + History { + all: keep( + "chat-conversations", + || conversations(Timestamp::now()), + window, + cx, + ), + active: keep( + "chat-conversation", + || Some(SharedString::from("lift")), + window, + cx, + ), + } +} + +fn conversations(now: Timestamp) -> Vec { + [ + ("lift", "What a lift does to a color", 0, false), + ("gamma", "Gamma and the eye", 3, true), + ("dark", "Designing the dark theme", 26, false), + ("type", "A type scale for the library", 30, false), + ("motion", "Durations that settle", 120, false), + ("icons", "Choosing an icon set", 200, true), + ("grid", "Spacing on a rem scale", 400, false), + ("release", "Release notes for 1.0", 2000, false), + ] + .into_iter() + .map(|(key, title, hours, pinned)| Conversation { + key: key.into(), + title: title.into(), + at: now - (hours as i64).hours(), + pinned, + }) + .collect() +} + +/// Adds `conversation` at the top unless its key is there, and opens it. +fn open_new(history: &History, conversation: Conversation, cx: &mut App) { + let key = conversation.key.clone(); + let mut next = history.all.read(cx).clone(); + if !next.iter().any(|item| item.key == key) { + next.insert(0, conversation); + set(&history.all, next, cx); + } + set(&history.active, Some(key), cx) +} + +pub fn conversations_section( + history: &History, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { + let today = Timestamp::now().to_zoned(TimeZone::system()).date(); + let query = window.use_keyed_state("chat-find-conversation", cx, |window, cx| { + TextInput::new(window, cx).placeholder("Search conversations") + }); + let naming = window.use_keyed_state("chat-rename-field", cx, |window, cx| { + TextInput::new(window, cx) + }); + let renaming = keep("chat-renaming", || None::, window, cx); + let (now_all, now_active, now_query, now_renaming) = ( + history.all.read(cx).clone(), + history.active.read(cx).clone(), + query.read(cx).text().to_string(), + renaming.read(cx).clone(), + ); + let (all, active) = (history.all.clone(), history.active.clone()); + let (fresh, picked, pinned, deleted, renamed) = ( + History { + all: all.clone(), + active: active.clone(), + }, + active.clone(), + all.clone(), + all.clone(), + all.clone(), + ); + let (asked, field, shut, done) = (renaming.clone(), naming.clone(), renaming.clone(), renaming); + let theme = cx.theme(); + section( + "ConversationList / ConversationItem / ConversationSearch / NewChatButton", + "Conversations down a sidebar, pinned first, then by day; the one open marked with its menu, the others show theirs on hover or focus. The search is a forms::SearchInput; the list keeps titles that hold every word.", + cx, + ) + .child(probe( + "chat-history", + div() + .w(px(300.)) + .h(px(520.)) + .flex() + .flex_col() + .gap_2() + .p_2() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.sunken) + .child(new_chat_button("chat-new").on_click(move |_, _, cx| { + let at = Timestamp::now(); + let key = SharedString::from(format!("new-{}", at.as_millisecond())); + open_new(&fresh, Conversation { key, title: "New chat".into(), at, pinned: false }, cx) + })) + .child(SearchInput::new("chat-find", &query)) + .child( + div().flex_1().min_h_0().child( + ConversationList::new("chat-list", now_all, today) + .query(now_query) + .active(now_active, move |key, _, cx| set(&picked, Some(key.clone()), cx)) + .actions( + move |key, _, cx| { + let title = all.read(cx).iter().find(|item| &item.key == key).map(|item| item.title.clone()); + let title = title.expect("a listed conversation has a title"); + field.update(cx, |input, cx| input.set_text(title, cx)); + set(&asked, Some(key.clone()), cx) + }, + move |key, _, cx| { + let mut next = pinned.read(cx).clone(); + let item = next.iter_mut().find(|item| &item.key == key).expect("a listed conversation"); + item.pinned = !item.pinned; + set(&pinned, next, cx) + }, + move |key, _, cx| { + let mut next = deleted.read(cx).clone(); + next.retain(|item| &item.key != key); + set(&deleted, next, cx) + }, + ), + ), + ), + )) + .children(now_renaming.map(|key| { + PromptDialog::new("chat-rename", "Rename conversation", &naming, move |_, cx| set(&shut, None, cx)) + .label("Title") + .submit("Rename") + .check(|text| match text.trim() { + "" => Err("A title cannot be empty.".into()), + _ => Ok(()), + }) + .on_submit(move |text, _, cx| { + let mut next = renamed.read(cx).clone(); + let item = next.iter_mut().find(|item| item.key == key).expect("the conversation being renamed"); + item.title = text.trim().to_string().into(); + set(&renamed, next, cx); + set(&done, None, cx) + }) + })) +} + +pub fn projects_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let active = keep( + "chat-project", + || Some(SharedString::from("library")), + window, + cx, + ); + let files = keep( + "chat-knowledge", + || { + vec![ + (SharedString::from("lift-notes.pdf"), 482_000_u64), + (SharedString::from("palette.json"), 12_400), + (SharedString::from("type-scale.md"), 6_800), + ] + }, + window, + cx, + ); + let (now_active, now_files) = (active.read(cx).clone(), files.read(cx).clone()); + let (added, removed) = (files.clone(), files); + section( + "FolderList / ProjectList / ProjectKnowledgePanel", + "Projects that hold conversations, each with how many; and what a project knows beyond the conversation, how full that knowledge is, with a way to add more.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_6() + .child( + div().w(px(240.)).child( + ProjectList::new( + "chat-projects", + [ + Project { key: "library".into(), name: "Component library".into(), count: 24 }, + Project { key: "site".into(), name: "The website".into(), count: 9 }, + Project { key: "notes".into(), name: "Color notes".into(), count: 3 }, + ], + ) + .active(now_active, move |key, _, cx| set(&active, Some(key.clone()), cx)), + ), + ) + .child( + div().w(px(360.)).child( + ProjectKnowledgePanel::new("chat-knowledge", now_files, 2_000_000) + .on_add(move |_, cx| { + let mut next = added.read(cx).clone(); + next.push((format!("reading-{}.md", next.len() + 1).into(), 3_200)); + set(&added, next, cx) + }) + .on_remove(move |ix, _, cx| { + let mut next = removed.read(cx).clone(); + next.remove(ix); + set(&removed, next, cx) + }), + ), + ), + ) +} + +const SHARED: [(&str, &str); 4] = [ + ("What does a lift do to a color?", ""), + ( + "", + "A **lift** blends a color toward white. Half a lift sits midway between the color and white.", + ), + ("And a shade?", ""), + ( + "", + "A **shade** blends toward black the same way. Dark themes build their surfaces from shades.", + ), +]; + +const FORMATS: [(&str, &str); 3] = [("markdown", "Markdown"), ("pdf", "PDF"), ("json", "JSON")]; + +pub fn shared_section( + history: &History, + toaster: &Entity, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { + let exporting = keep("chat-exporting", || false, window, cx); + let format = keep( + "chat-export-format", + || SharedString::from("markdown"), + window, + cx, + ); + let thinking = keep("chat-export-thinking", || false, window, cx); + let (open, now_format, now_thinking) = ( + *exporting.read(cx), + format.read(cx).clone(), + *thinking.read(cx), + ); + let (opener, closer) = (exporting.clone(), exporting); + let (carried, toaster) = ( + History { + all: history.all.clone(), + active: history.active.clone(), + }, + toaster.clone(), + ); + let messages = MessageList::new("chat-shared-messages", SHARED.len(), |ix, _, _| { + let (asked, answered) = SHARED[ix]; + let bubble = if asked.is_empty() { + MessageBubble::new(Role::Assistant) + .avatar(MessageAvatar::new( + ("chat-shared-mark", ix), + Role::Assistant, + "Assistant", + )) + .child(StreamingMarkdown::new( + ("chat-shared-text", ix), + answered, + false, + )) + } else { + MessageBubble::new(Role::User).child(asked) + }; + div().px_6().py_3().child(bubble).into_any_element() + }); + let theme = cx.theme(); + section( + "SharedConversationView / ConversationExport", + "A conversation someone shared, to read and carry on; and saving one to a file, in a format, with or without its thinking.", + cx, + ) + .child( + div() + .w(px(640.)) + .h(px(300.)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child( + SharedConversationView::new("chat-shared", "Grace Lin", Timestamp::now() - 3.hours(), messages).on_continue(move |_, cx| { + let conversation = Conversation { key: "shared-lift".into(), title: SHARED[0].0.into(), at: Timestamp::now(), pinned: false }; + open_new(&carried, conversation, cx) + }), + ), + ) + .child(probe( + "chat-export-open", + Button::new("chat-export", "Export…") + .variant(ButtonVariant::Secondary) + .icon(IconName::Download) + .on_click(move |_, _, cx| set(&opener, true, cx)), + )) + .children(open.then(|| { + let label = FORMATS.iter().find(|(value, _)| *value == now_format.as_ref()).expect("a known format").1; + ConversationExport::new( + "chat-export-dialog", + now_format, + move |next, _, cx| set(&format, next.clone(), cx), + move |_, cx| { + toaster.update(cx, |toaster, cx| { + toaster.push(Toast::new(format!("Exported as {label}")).severity(Severity::Success), cx); + }) + }, + move |_, cx| set(&closer, false, cx), + ) + .thinking(now_thinking, move |on, _, cx| set(&thinking, on, cx)) + })) +} diff --git a/examples/gallery/pages/chat/mod.rs b/examples/gallery/pages/chat/mod.rs index 19fbe67..2cdd1ea 100644 --- a/examples/gallery/pages/chat/mod.rs +++ b/examples/gallery/pages/chat/mod.rs @@ -2,12 +2,14 @@ mod blocks; mod cite; mod compose; mod controls; +mod history; mod talk; +use ely_gpui_component::feedback::{ToastViewport, Toaster}; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; use super::Page; -use crate::script::Step; +use crate::{script::Step, ui::keep}; pub const PAGE: Page = Page { number: 23, @@ -54,12 +56,22 @@ const SCRIPT: &[Step] = &[ Step::UpAt("chat-system", 548.0, 12.0), Step::Wait(200), Step::Shot("reset"), + Step::HoverAt("chat-history", 150.0, 124.0), + Step::Wait(300), + Step::Shot("history"), + Step::Click("chat-export-open"), + Step::Wait(300), + Step::Shot("export"), + Step::Key("escape"), + Step::Wait(200), Step::DownAt("chat-sources", 20.0, 10.0), Step::UpAt("chat-sources", 20.0, 10.0), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { let draft = compose::draft(window, cx); + let history = history::history(window, cx); + let toaster = keep("chat-toaster", Toaster::default, window, cx); div() .child(talk::conversation(window, cx)) .child(talk::streaming(window, cx)) @@ -74,5 +86,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(compose::composer(&draft, window, cx)) .child(compose::pickers(&draft, window, cx)) .child(compose::tuning(window, cx)) + .child(history::conversations_section(&history, window, cx)) + .child(history::projects_section(window, cx)) + .child(history::shared_section(&history, &toaster, window, cx)) + .child(ToastViewport::new("chat-toasts", &toaster)) .into_any_element() } diff --git a/src/chat/conversations.rs b/src/chat/conversations.rs new file mode 100644 index 0000000..5d66c53 --- /dev/null +++ b/src/chat/conversations.rs @@ -0,0 +1,424 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + transparent_black, +}; +use jiff::{Timestamp, civil::Date, tz::TimeZone}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::OnValue, + menus::{Menu, MenuItem, OverflowMenu}, + primitives::{FocusRing, Icon, IconName, tab_stop}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// A conversation in a list: its key, its title, when it last moved, and whether it is pinned. +#[derive(Clone, Debug, PartialEq)] +pub struct Conversation { + pub key: SharedString, + pub title: SharedString, + pub at: Timestamp, + pub pinned: bool, +} + +/// The heading a conversation files under by the day it last moved, seen from `today`. +pub(crate) fn group_name(day: Date, today: Date) -> String { + let days = (today - day).get_days(); + match days { + ..=0 => "Today".to_string(), + 1 => "Yesterday".to_string(), + 2..=7 => "Previous 7 days".to_string(), + 8..=30 => "Previous 30 days".to_string(), + _ if day.year() == today.year() => day.strftime("%B").to_string(), + _ => day.strftime("%B %Y").to_string(), + } +} + +/// Whether every word of `query` is in `title`, ignoring case. +pub(crate) fn matches(title: &str, query: &str) -> bool { + let title = title.to_lowercase(); + query + .split_whitespace() + .all(|word| title.contains(&word.to_lowercase())) +} + +/// The conversations `query` keeps, pinned first, then under their day's heading, newest first; each group holds indexes. +pub(crate) fn grouped( + conversations: &[Conversation], + query: &str, + today: Date, + zone: &TimeZone, +) -> Vec<(String, Vec)> { + let mut kept: Vec = (0..conversations.len()) + .filter(|ix| matches(&conversations[*ix].title, query)) + .collect(); + kept.sort_by_key(|ix| std::cmp::Reverse(conversations[*ix].at)); + let mut groups: Vec<(String, Vec)> = Vec::new(); + let pinned: Vec = kept + .iter() + .copied() + .filter(|ix| conversations[*ix].pinned) + .collect(); + if !pinned.is_empty() { + groups.push(("Pinned".to_string(), pinned)); + } + for ix in kept.into_iter().filter(|ix| !conversations[*ix].pinned) { + let name = group_name(conversations[ix].at.to_zoned(zone.clone()).date(), today); + match groups.last_mut() { + Some((last, items)) if *last == name => items.push(ix), + _ => groups.push((name, vec![ix])), + } + } + groups +} + +/// Opens a new conversation; it shows Command-N, which the host binds. +pub fn new_chat_button(id: impl Into) -> Button { + Button::new(id, "New chat") + .variant(ButtonVariant::Secondary) + .icon(IconName::SquarePen) + .shortcut("secondary-n") +} + +type OnKey = OnValue; + +/// One conversation in the list: its title, a pin when pinned, the one open marked with its menu of rename, pin and delete, which the others show on hover or focus. +#[derive(IntoElement)] +pub struct ConversationItem { + id: ElementId, + conversation: Conversation, + active: bool, + on_select: Option, + on_rename: Option, + on_pin: Option, + on_delete: Option, +} + +impl ConversationItem { + pub fn new(id: impl Into, conversation: Conversation) -> Self { + Self { + id: id.into(), + conversation, + active: false, + on_select: None, + on_rename: None, + on_pin: None, + on_delete: None, + } + } + + pub fn active(mut self, active: bool) -> Self { + self.active = active; + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Rename, pin and delete, each given the conversation's key. + pub fn actions( + mut self, + on_rename: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_pin: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_delete: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rename = Some(Rc::new(on_rename)); + self.on_pin = Some(Rc::new(on_pin)); + self.on_delete = Some(Rc::new(on_delete)); + self + } +} + +impl RenderOnce for ConversationItem { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let key = self.conversation.key.clone(); + let group = SharedString::from(format!("conversation-{}", self.id)); + let menu = match (self.on_rename, self.on_pin, self.on_delete) { + (Some(rename), Some(pin), Some(delete)) => { + let (a, b, c) = (key.clone(), key.clone(), key.clone()); + Some( + Menu::new() + .item( + MenuItem::new("Rename") + .icon(IconName::Pencil) + .on_click(move |window, cx| rename(&a, window, cx)), + ) + .item( + match self.conversation.pinned { + true => MenuItem::new("Unpin").icon(IconName::PinOff), + false => MenuItem::new("Pin").icon(IconName::Pin), + } + .on_click(move |window, cx| pin(&b, window, cx)), + ) + .separator() + .item( + MenuItem::new("Delete") + .icon(IconName::Trash2) + .on_click(move |window, cx| delete(&c, window, cx)), + ), + ) + } + _ => None, + }; + let select = self.on_select; + let pressable = select.is_some() || menu.is_some(); + let focus = tab_stop((self.id.clone(), "focus").into(), pressable, window, cx); + let shown = self.active || focus.contains_focused(window, cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .id(self.id.clone()) + .group(group.clone()) + .track_focus(&focus) + .w_full() + .flex() + .items_center() + .gap_1() + .h(theme.control_height(ControlSize::Md)) + .pl_2() + .pr_0p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(transparent_black()) + .focus_ring(cx) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if self.active { + colors.fg + } else { + colors.fg_muted + }) + .when(self.active, |row| row.bg(colors.active)) + .when_some(select, |row, select| { + let key = key.clone(); + row.cursor_pointer() + .when(!self.active, |row| row.hover(|row| row.bg(colors.hover))) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("conversations: open {key}"); + select(&key, window, cx) + }) + }) + .when(self.conversation.pinned, |row| { + row.child( + Icon::new(IconName::Pin) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(self.conversation.title.clone())), + ) + .children(menu.map(|menu| { + div() + .flex_none() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when(!shown, |slot| { + slot.invisible().group_hover(group, |style| style.visible()) + }) + .child( + OverflowMenu::new((self.id.clone(), "menu"), menu) + .tooltip("Conversation actions"), + ) + })) + } +} + +/// Conversations down a column, pinned first, then under their day, newest first; a query keeps those whose titles hold every word. +#[derive(IntoElement)] +pub struct ConversationList { + id: ElementId, + conversations: Vec, + active: Option, + query: SharedString, + today: Date, + on_select: Option, + actions: Option<(OnKey, OnKey, OnKey)>, +} + +impl ConversationList { + /// `today` names the reader's day, so tests and captures hold still. + pub fn new( + id: impl Into, + conversations: impl IntoIterator, + today: Date, + ) -> Self { + Self { + id: id.into(), + conversations: conversations.into_iter().collect(), + active: None, + query: SharedString::default(), + today, + on_select: None, + actions: None, + } + } + + /// The conversation open, by key, and what a press on another asks. + pub fn active( + mut self, + active: Option, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.active = active; + self.on_select = Some(Rc::new(on_select)); + self + } + + /// Keeps the conversations whose titles hold every word of `query`, such as a `forms::SearchInput`'s. + pub fn query(mut self, query: impl Into) -> Self { + self.query = query.into(); + self + } + + /// Rename, pin and delete on each conversation. + pub fn actions( + mut self, + on_rename: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_pin: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_delete: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.actions = Some((Rc::new(on_rename), Rc::new(on_pin), Rc::new(on_delete))); + self + } +} + +impl RenderOnce for ConversationList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let groups = grouped( + &self.conversations, + &self.query, + self.today, + &TimeZone::system(), + ); + let empty = groups.is_empty(); + div() + .id(self.id.clone()) + .size_full() + .overflow_y_scroll() + .flex() + .flex_col() + .gap_3() + .p_2() + .when(empty, |list| { + list.child( + div() + .px_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle) + .child(if self.query.trim().is_empty() { + "No conversations yet" + } else { + "No conversations match" + }), + ) + }) + .children(groups.into_iter().map(|(name, items)| { + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .px_2() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_subtle) + .child(name), + ) + .children(items.into_iter().map(|ix| { + let conversation = self.conversations[ix].clone(); + let active = self.active.as_ref() == Some(&conversation.key); + let item = ConversationItem::new( + (self.id.clone(), format!("item-{}", conversation.key)), + conversation, + ) + .active(active); + let item = match self.on_select.clone() { + Some(select) => { + item.on_select(move |key, window, cx| select(key, window, cx)) + } + None => item, + }; + match self.actions.clone() { + Some((rename, pin, delete)) => item.actions( + move |key, window, cx| rename(key, window, cx), + move |key, window, cx| pin(key, window, cx), + move |key, window, cx| delete(key, window, cx), + ), + None => item, + } + })) + })) + } +} + +#[cfg(test)] +mod tests { + use jiff::{ToSpan, civil::date}; + + use super::*; + + fn conversation(key: &str, days_ago: i64, pinned: bool) -> Conversation { + let noon = date(2026, 9, 26) + .at(12, 0, 0, 0) + .to_zoned(TimeZone::UTC) + .expect("noon exists"); + Conversation { + key: key.to_string().into(), + title: format!("About {key}").into(), + at: (noon - days_ago.days()).timestamp(), + pinned, + } + } + + #[test] + fn days_name_their_groups() { + let today = date(2026, 9, 26); + assert_eq!(group_name(today, today), "Today"); + assert_eq!(group_name(date(2026, 9, 25), today), "Yesterday"); + assert_eq!(group_name(date(2026, 9, 21), today), "Previous 7 days"); + assert_eq!(group_name(date(2026, 9, 1), today), "Previous 30 days"); + assert_eq!(group_name(date(2026, 3, 4), today), "March"); + assert_eq!(group_name(date(2025, 12, 31), today), "December 2025"); + } + + #[test] + fn pinned_come_first_then_each_day_newest_first() { + let all = [ + conversation("lift", 0, false), + conversation("gamma", 3, true), + conversation("dark", 1, false), + conversation("type", 0, false), + ]; + let groups = grouped(&all, "", date(2026, 9, 26), &TimeZone::UTC); + let names: Vec<(&str, Vec)> = groups + .iter() + .map(|(name, items)| (name.as_str(), items.clone())) + .collect(); + assert_eq!( + names, + [ + ("Pinned", vec![1]), + ("Today", vec![0, 3]), + ("Yesterday", vec![2]) + ] + ); + let found = grouped(&all, "ABOUT dark", date(2026, 9, 26), &TimeZone::UTC); + assert_eq!(found, [("Yesterday".to_string(), vec![2])]); + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 472e356..b4eb343 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -3,11 +3,13 @@ mod attach; mod cite; mod code; mod composer; +mod conversations; mod list; mod media; mod message; mod params; mod play; +mod projects; mod search; mod status; mod stream; @@ -23,6 +25,7 @@ pub use attach::{Attachment, AttachmentChip, ContextChips}; pub use cite::{CitationBadge, Source, SourceCard, SourceList}; pub use code::CodeBlock; pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton}; +pub use conversations::{Conversation, ConversationItem, ConversationList, new_chat_button}; pub use list::{DateSeparator, MessageList, ScrollToBottomButton}; pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard}; pub use message::{ @@ -30,6 +33,9 @@ pub use message::{ }; pub use params::{CostEstimator, Parameter, ParameterPanel, SystemPromptEditor, TokenCounter}; pub use play::{AudioMessage, VideoMessage}; +pub use projects::{ + ConversationExport, Project, ProjectKnowledgePanel, ProjectList, SharedConversationView, +}; pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard}; pub use status::{ ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator, diff --git a/src/chat/projects.rs b/src/chat/projects.rs new file mode 100644 index 0000000..30d00dd --- /dev/null +++ b/src/chat/projects.rs @@ -0,0 +1,417 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, transparent_black, +}; +use jiff::Timestamp; + +use super::media::file_icon; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + data_display::Meter, + forms::{CheckState, Checkbox, Choice, OnFlag, OnValue, Pick, RadioGroup, Run}, + overlays::Dialog, + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{Ellipsis, RelativeTime, format::file_size, tabular}, +}; + +type OnKey = OnValue; + +/// A project that holds conversations: its key, name, and how many it holds. +#[derive(Clone, Debug, PartialEq)] +pub struct Project { + pub key: SharedString, + pub name: SharedString, + pub count: usize, +} + +/// Projects in a column, each with how many conversations it holds, the open one marked; a press opens one. +#[derive(IntoElement)] +pub struct ProjectList { + id: ElementId, + projects: Vec, + active: Option, + on_select: Option, +} + +impl ProjectList { + pub fn new(id: impl Into, projects: impl IntoIterator) -> Self { + Self { + id: id.into(), + projects: projects.into_iter().collect(), + active: None, + on_select: None, + } + } + + pub fn active( + mut self, + active: Option, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.active = active; + self.on_select = Some(Rc::new(on_select)); + self + } +} + +impl RenderOnce for ProjectList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .w_full() + .flex() + .flex_col() + .gap_0p5() + .children(self.projects.into_iter().map(|project| { + let active = self.active.as_ref() == Some(&project.key); + let select = self.on_select.clone(); + let key = project.key.clone(); + div() + .id((self.id.clone(), format!("project-{key}"))) + .flex() + .items_center() + .gap_2() + .h(theme.control_height(ControlSize::Md)) + .px_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(transparent_black()) + .text_size(theme.text_size(TextSize::Sm)) + .when(active, |row| row.bg(colors.active)) + .when_some(select, |row, select| { + row.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .when(!active, |row| row.hover(|row| row.bg(colors.hover))) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + log::info!("projects: open {key}"); + select(&key, window, cx) + }) + }) + .child( + Icon::new(IconName::Folder) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child(div().flex_1().min_w_0().child(Ellipsis::new(project.name))) + .child( + tabular( + div() + .flex_none() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle), + ) + .child(project.count.to_string()), + ) + })) + } +} + +/// What a project knows beyond the conversation: its files, how full that knowledge is, a way to add more and to take one off. +#[derive(IntoElement)] +pub struct ProjectKnowledgePanel { + id: ElementId, + files: Vec<(SharedString, u64)>, + capacity: u64, + on_add: Option, + on_remove: Option, +} + +impl ProjectKnowledgePanel { + /// `files` by name and size; `capacity` is how much the project holds, in bytes. + pub fn new( + id: impl Into, + files: impl IntoIterator, u64)>, + capacity: u64, + ) -> Self { + assert!(capacity > 0, "a project holds something"); + Self { + id: id.into(), + files: files + .into_iter() + .map(|(name, bytes)| (name.into(), bytes)) + .collect(), + capacity, + on_add: None, + on_remove: None, + } + } + + pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_add = Some(Rc::new(handler)); + self + } + + /// Gets the index of the file to take off. + pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ProjectKnowledgePanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let used: u64 = self.files.iter().map(|(_, bytes)| bytes).sum(); + let share = used as f32 / self.capacity as f32; + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child("Project knowledge"), + ) + .children(self.on_add.map(|add| { + Button::new((self.id.clone(), "add"), "Add files") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .icon(IconName::Plus) + .on_click(move |_, window, cx| add(window, cx)) + })), + ) + .child( + Meter::new((self.id.clone(), "used"), "Capacity", share.min(1.0)).detail(format!( + "{} of {}", + file_size(used, false), + file_size(self.capacity, false) + )), + ) + .child( + div() + .flex() + .flex_col() + .gap_0p5() + .children( + self.files + .into_iter() + .enumerate() + .map(|(ix, (name, bytes))| { + let remove = self.on_remove.clone(); + div() + .flex() + .items_center() + .gap_2() + .py_1() + .text_size(theme.text_size(TextSize::Sm)) + .child( + Icon::new(file_icon(&name)) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child(div().flex_1().min_w_0().child(Ellipsis::new(name))) + .child( + tabular( + div() + .flex_none() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle), + ) + .child(file_size(bytes, false)), + ) + .children(remove.map(|remove| { + IconButton::new( + (self.id.clone(), format!("remove-{ix}")), + IconName::X, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Remove") + .on_click( + move |_, window, cx| { + log::info!("project knowledge: remove {ix}"); + remove(ix, window, cx) + }, + ) + })) + }), + ), + ) + } +} + +/// A conversation someone shared, to read: who shared it and when, its messages as they stood, and a way to carry it on. +#[derive(IntoElement)] +pub struct SharedConversationView { + id: ElementId, + sharer: SharedString, + at: Timestamp, + messages: AnyElement, + on_continue: Option, +} + +impl SharedConversationView { + /// `messages` show the conversation, such as a `MessageList`. + pub fn new( + id: impl Into, + sharer: impl Into, + at: Timestamp, + messages: impl IntoElement, + ) -> Self { + Self { + id: id.into(), + sharer: sharer.into(), + at, + messages: messages.into_any_element(), + on_continue: None, + } + } + + pub fn on_continue(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_continue = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SharedConversationView { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .size_full() + .flex() + .flex_col() + .child( + div() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .px_4() + .py_2() + .border_b_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::Link) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(format!("Shared by {}", self.sharer)) + .child("·") + .child(RelativeTime::new((self.id.clone(), "when"), self.at)) + .child(div().flex_1()) + .children(self.on_continue.map(|carry| { + Button::new((self.id.clone(), "continue"), "Continue this conversation") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| carry(window, cx)) + })), + ) + .child(div().flex_1().min_h_0().child(self.messages)) + } +} + +const FORMATS: [(&str, &str); 3] = [("markdown", "Markdown"), ("pdf", "PDF"), ("json", "JSON")]; + +/// Saves a conversation to a file: the format, whether its thinking goes with it, then export or cancel. Render it while open. +#[derive(IntoElement)] +pub struct ConversationExport { + id: ElementId, + format: SharedString, + on_format: OnValue, + thinking: Option<(bool, OnFlag)>, + on_export: Run, + on_close: Run, +} + +impl ConversationExport { + /// `format` is markdown, pdf or json; `on_close` runs on Cancel, Escape or a press outside. + pub fn new( + id: impl Into, + format: impl Into, + on_format: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_export: impl Fn(&mut Window, &mut App) + 'static, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + let format = format.into(); + assert!( + FORMATS.iter().any(|(value, _)| *value == format.as_ref()), + "no export format {format}" + ); + Self { + id: id.into(), + format, + on_format: Rc::new(on_format), + thinking: None, + on_export: Rc::new(on_export), + on_close: Rc::new(on_close), + } + } + + /// Offers to include the thinking behind each answer. + pub fn thinking( + mut self, + included: bool, + on_change: impl Fn(bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.thinking = Some((included, Rc::new(on_change))); + self + } +} + +impl RenderOnce for ConversationExport { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (pick, export, close) = (self.on_format, self.on_export, self.on_close.clone()); + let format = self.format.clone(); + let (cancel, done) = ((self.id.clone(), "cancel"), (self.id.clone(), "export")); + Dialog::new(self.id.clone(), "Export conversation", move |window, cx| { + close(window, cx) + }) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child( + RadioGroup::new( + (self.id.clone(), "format"), + FORMATS.map(|(value, label)| Choice::new(value, label)), + ) + .selected(self.format) + .on_change(move |next, window, cx| { + log::info!("export: format {next}"); + pick(next, window, cx) + }), + ) + .children(self.thinking.map(|(included, change)| { + Checkbox::new((self.id.clone(), "thinking"), CheckState::from(included)) + .label("Include the thinking behind each answer") + .on_change(move |on, window, cx| change(on, window, cx)) + })), + ) + .action(move |close| { + Button::new(cancel, "Cancel") + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |close| { + Button::new(done, "Export") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + log::info!("export: {format}"); + export(window, cx); + close(window, cx) + }) + }) + } +} diff --git a/src/chat/tests/history.rs b/src/chat/tests/history.rs new file mode 100644 index 0000000..e9bdfe6 --- /dev/null +++ b/src/chat/tests/history.rs @@ -0,0 +1,98 @@ +use gpui::{ + Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, SharedString, + Styled, TestAppContext, VisualTestContext, Window, div, point, px, +}; +use jiff::Timestamp; + +use super::settle; +use crate::{ + chat::{Conversation, ConversationItem}, + forms, + theme::Theme, +}; + +/// One conversation's row, and what it asked: opened or renamed. +struct Row { + opened: Vec, + renamed: Vec, +} + +impl Render for Row { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (open, rename) = (cx.entity(), cx.entity()); + let conversation = Conversation { + key: "lift".into(), + title: "What a lift does".into(), + at: Timestamp::UNIX_EPOCH, + pinned: false, + }; + div().w(px(300.0)).child( + ConversationItem::new("row", conversation) + .on_select(move |key, _, cx| open.update(cx, |row, _| row.opened.push(key.clone()))) + .actions( + move |key, _, cx| rename.update(cx, |row, _| row.renamed.push(key.clone())), + |_, _, _| {}, + |_, _, _| {}, + ), + ) + } +} + +fn row(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (row, cx) = cx.add_window_view(|_, _| Row { + opened: Vec::new(), + renamed: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (row, cx) +} + +/// Presses and releases `key`, a frame apart. +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +#[gpui::test] +fn a_rows_actions_open_from_the_keyboard(cx: &mut TestAppContext) { + let (row, cx) = row(cx); + cx.simulate_mouse_move(point(px(10.0), px(200.0)), None, Modifiers::none()); + settle(cx); + cx.update(|window, _| window.focus_next()); + settle(cx); + cx.update(|window, _| window.focus_next()); + settle(cx); + press("enter", cx); + press("enter", cx); + row.read_with(cx, |row, _| { + assert_eq!( + row.renamed, + ["lift"], + "Tab reaches the menu of the row in focus" + ); + assert!(row.opened.is_empty()); + }); +} + +#[gpui::test] +fn a_press_on_the_menu_leaves_the_row_shut(cx: &mut TestAppContext) { + let (row, cx) = row(cx); + let (title, menu) = (point(px(60.0), px(14.0)), point(px(285.0), px(14.0))); + cx.simulate_click(title, Modifiers::none()); + settle(cx); + cx.simulate_mouse_move(menu, None, Modifiers::none()); + settle(cx); + cx.simulate_click(menu, Modifiers::none()); + settle(cx); + let opened = row.read_with(cx, |row, _| row.opened.clone()); + assert_eq!(opened, ["lift"], "only the press on the title opens it"); +} diff --git a/src/chat/tests/mod.rs b/src/chat/tests/mod.rs index 0ea2afa..75fc161 100644 --- a/src/chat/tests/mod.rs +++ b/src/chat/tests/mod.rs @@ -9,6 +9,7 @@ use gpui::{ mod cite; mod compose; +mod history; use super::{ ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock, diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index f4ac49b..1deb609 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -109,6 +109,7 @@ icons! { Compass => "compass", Contrast => "contrast", Copy => "copy", + CornerDownRight => "corner-down-right", Cpu => "cpu", CreditCard => "credit-card", Crop => "crop", @@ -249,6 +250,7 @@ icons! { SquareCheck => "square-check", Square => "square", SquareFunction => "square-function", + SquarePen => "square-pen", Star => "star", StepForward => "step-forward", Strikethrough => "strikethrough", diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 5c2b3e1..aff5349 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -145,14 +145,14 @@ ## T23d AI Chat · Conversations -- [ ] ConversationList -- [ ] ConversationItem -- [ ] ConversationSearch -- [ ] NewChatButton -- [ ] FolderList / ProjectList -- [ ] ProjectKnowledgePanel -- [ ] SharedConversationView -- [ ] ConversationExport +- [x] ConversationList +- [x] ConversationItem +- [x] ConversationSearch → `forms::SearchInput` over `chat::ConversationList::query` +- [x] NewChatButton → `chat::new_chat_button`, a `buttons::Button` +- [x] FolderList / ProjectList → `chat::ProjectList` +- [x] ProjectKnowledgePanel +- [x] SharedConversationView +- [x] ConversationExport ## T23e AI Chat · Welcome