From 6151291443b46315e0bab70aa69afd7c38da6e53 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 05:35:09 -0400 Subject: [PATCH] T23a: AI chat, messages - messages: yours in a quiet bubble, the assistant's as prose beside its mark, system notes, errors; avatars, headers with model and time, footers - MessageList over gpui's bottom-aligned list: drawn near the view, held at the newest, counting arrivals while away; a way back down; day separators - streaming text and markdown reveal toward the text as it grows, with a caret; a blinking cursor before the first words - CodeBlock with language, copy, run, apply, download and folding; MarkdownRenderer::code lets messages draw fences with it - pictures, grids, files, voice notes, video over host frames, link previews - quotes, actions, an editor that takes focus, versions, regenerate, stop and continue - errors with retry, a rate limit counting down, feedback, thinking blocks - gallery: the AI Chat page with scripted states --- AGENTS.md | 1 + TASKS.md | 2 +- examples/gallery/assets/flow.svg | 19 ++ examples/gallery/pages/chat/blocks.rs | 141 ++++++++ examples/gallery/pages/chat/controls.rs | 184 ++++++++++ examples/gallery/pages/chat/mod.rs | 45 +++ examples/gallery/pages/chat/talk.rs | 224 +++++++++++++ examples/gallery/pages/mod.rs | 2 + src/chat/actions.rs | 377 +++++++++++++++++++++ src/chat/code.rs | 192 +++++++++++ src/chat/list.rs | 262 +++++++++++++++ src/chat/media.rs | 425 ++++++++++++++++++++++++ src/chat/message.rs | 339 +++++++++++++++++++ src/chat/mod.rs | 26 ++ src/chat/play.rs | 300 +++++++++++++++++ src/chat/status.rs | 420 +++++++++++++++++++++++ src/chat/stream.rs | 199 +++++++++++ src/chat/tests.rs | 164 +++++++++ src/documents/render.rs | 21 ++ src/lib.rs | 1 + tasks/ch21-30.md | 66 ++-- 21 files changed, 3376 insertions(+), 34 deletions(-) create mode 100644 examples/gallery/assets/flow.svg create mode 100644 examples/gallery/pages/chat/blocks.rs create mode 100644 examples/gallery/pages/chat/controls.rs create mode 100644 examples/gallery/pages/chat/mod.rs create mode 100644 examples/gallery/pages/chat/talk.rs create mode 100644 src/chat/actions.rs create mode 100644 src/chat/code.rs create mode 100644 src/chat/list.rs create mode 100644 src/chat/media.rs create mode 100644 src/chat/message.rs create mode 100644 src/chat/mod.rs create mode 100644 src/chat/play.rs create mode 100644 src/chat/status.rs create mode 100644 src/chat/stream.rs create mode 100644 src/chat/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 8052f25..04ba948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them. - Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`). - A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`). +- A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`). - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. - A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width. diff --git a/TASKS.md b/TASKS.md index 431b8db..814e78a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -72,7 +72,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T21b Documents · Reading - [x] T21c Documents · Knowledge - [x] T22 Collaboration -- [ ] T23a AI Chat · Messages +- [x] T23a AI Chat · Messages - [ ] T23b AI Chat · Citations - [ ] T23c AI Chat · Input - [ ] T23d AI Chat · Conversations diff --git a/examples/gallery/assets/flow.svg b/examples/gallery/assets/flow.svg new file mode 100644 index 0000000..e7a5560 --- /dev/null +++ b/examples/gallery/assets/flow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + Base color + + Lift + t = 0.5 or 0.62 + + Theme accent + + + + diff --git a/examples/gallery/pages/chat/blocks.rs b/examples/gallery/pages/chat/blocks.rs new file mode 100644 index 0000000..499a4c6 --- /dev/null +++ b/examples/gallery/pages/chat/blocks.rs @@ -0,0 +1,141 @@ +use std::time::Duration; + +use ely_gpui_component::{ + chat::{ + AudioMessage, CodeBlock, FileMessage, ImageGrid, ImageMessage, LinkPreviewCard, + VideoMessage, + }, + documents::MarkdownRenderer, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use crate::ui::{keep, noise, section, set}; + +macro_rules! asset { + ($name:literal) => { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/", + $name + ) + }; +} + +const SHORT: &str = "fn lift(c: f32, t: f32) -> f32 {\n c + t * (1.0 - c)\n}"; + +const LONG: &str = "/// Every accent in the library, lifted for its theme.\npub struct Accents {\n base: Hsla,\n light: f32,\n dark: f32,\n}\n\nimpl Accents {\n pub fn new(base: Hsla) -> Self {\n Self { base, light: 0.5, dark: 0.62 }\n }\n\n /// The accent as the theme shows it.\n pub fn shown(&self, dark: bool) -> Hsla {\n let t = if dark { self.dark } else { self.light };\n lift(self.base, t)\n }\n\n /// A hover, one step louder.\n pub fn hover(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.08)\n }\n\n /// A press, two steps louder.\n pub fn press(&self, dark: bool) -> Hsla {\n lift(self.shown(dark), 0.16)\n }\n}"; + +pub fn code(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + section( + "CodeBlock", + "Code in a message: its language, copy, and the host's run, apply and download. Long code folds to its first lines until opened.", + cx, + ) + .child( + div() + .w(px(620.)) + .flex() + .flex_col() + .gap_4() + .child( + CodeBlock::new("chat-code-short", SHORT) + .language("rust") + .on_run(|_, _| log::info!("gallery: run")) + .on_apply(|_, _| log::info!("gallery: apply")) + .on_download(|_, _| log::info!("gallery: download")), + ) + .child(CodeBlock::new("chat-code-long", LONG).language("rust")), + ) +} + +const RICH: &str = "| Theme | Lift | Reads as |\n| --- | ---: | --- |\n| Light | 0.50 | half toward white |\n| Dark | 0.62 | a little louder |\n\n$$c' = c + t\\,(1 - c)$$"; + +pub fn rich(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + section( + "MathBlock / TableBlock / MermaidRenderer", + "Math is typography::Latex and tables are the markdown renderer's, in a message as in a document. A diagram arrives as the picture the host drew from its source.", + cx, + ) + .child( + div() + .w(px(620.)) + .flex() + .flex_col() + .gap_4() + .child(MarkdownRenderer::new("chat-rich", RICH)) + .child(ImageMessage::new("chat-diagram", asset!("flow.svg"), 560.0, 160.0).caption("Drawn by the host from a diagram's source")), + ) +} + +pub fn media(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let mut wave = noise(7); + let peaks: Vec = (0..56) + .map(|ix| { + let swell = (ix as f32 / 56.0 * std::f32::consts::PI).sin(); + (0.25 + 0.75 * swell * wave() as f32).clamp(0.08, 1.0) + }) + .collect(); + let playing = keep( + "chat-audio", + || (Duration::from_secs(12), false), + window, + cx, + ); + let (played, on) = *playing.read(cx); + let (toggle, seek) = (playing.clone(), playing); + section( + "ImageMessage / ImageGrid / FileMessage / AudioMessage / VideoMessage / LinkPreviewCard", + "What a message can carry: a picture in its own shape, pictures as a grid, a file, a voice note to play and scrub, a video over the host's frames, and a link unfolded.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div() + .flex() + .flex_col() + .gap_4() + .child(ImageMessage::new("chat-image", asset!("atrium.jpg"), 960.0, 640.0).caption("The atrium at noon")) + .child(FileMessage::new("chat-file", "lift-notes.pdf", 482_000).on_download(|_, _| log::info!("gallery: download file"))) + .child( + AudioMessage::new("chat-audio", peaks, Duration::from_secs(42)) + .played(played, on) + .on_toggle(move |next, _, cx| { + let at = toggle.read(cx).0; + set(&toggle, (at, next), cx) + }) + .on_seek(move |share, _, cx| { + let on = seek.read(cx).1; + set(&seek, (Duration::from_secs_f32(42.0 * share), on), cx) + }), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child(ImageGrid::new( + "chat-grid", + [ + asset!("dunes.jpg"), + asset!("atrium-stair.jpg"), + asset!("dunes-sun.jpg"), + asset!("atrium-olive.jpg"), + asset!("dunes-grass.jpg"), + ], + )) + .child(VideoMessage::new("chat-video", asset!("dunes.jpg"), 960.0, 640.0, Duration::from_secs(84)).on_toggle(|next, _, _| log::info!("gallery: video playing {next}"))) + .child( + LinkPreviewCard::new("chat-link", "example.com", "Color, in light and dark") + .description("How one accent reads the same on paper and on charcoal.") + .picture(asset!("dunes-square.jpg")) + .on_open(|_, _| log::info!("gallery: open link")), + ), + ), + ) +} diff --git a/examples/gallery/pages/chat/controls.rs b/examples/gallery/pages/chat/controls.rs new file mode 100644 index 0000000..2b7dd77 --- /dev/null +++ b/examples/gallery/pages/chat/controls.rs @@ -0,0 +1,184 @@ +use std::time::{Duration, Instant}; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + chat::{ + BranchNavigator, ErrorMessage, FeedbackForm, MessageActions, MessageAvatar, MessageBubble, + MessageEditor, QuoteReply, RateLimitNotice, Role, ThinkingBlock, ThinkingDuration, + ThinkingIndicator, continue_button, regenerate_button, stop_button, + }, + forms::TextInput, + primitives::IconName, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const SENT: &str = "What does a lift do to a color?"; + +fn field( + key: &'static str, + text: &str, + hint: &str, + window: &mut Window, + cx: &mut App, +) -> Entity { + let (text, hint) = (text.to_string(), hint.to_string()); + window.use_keyed_state(key, cx, move |window, cx| { + let mut input = TextInput::new(window, cx) + .multi_line(2, 6) + .placeholder(hint); + input.set_text(text, cx); + input + }) +} + +pub fn actions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let rating = keep("chat-rating", || None::, window, cx); + let now_rating = *rating.read(cx); + let branch = keep("chat-branch", || 1_usize, window, cx); + let now_branch = *branch.read(cx); + let editing = keep("chat-editing", || false, window, cx); + let sent = keep("chat-sent", || SharedString::from(SENT), window, cx); + let now_sent = sent.read(cx).clone(); + let draft = field("chat-draft", SENT, "Your message", window, cx); + let open = editing.clone(); + let body = if *editing.read(cx) { + let (save, cancel, text) = (editing.clone(), editing.clone(), draft.clone()); + MessageEditor::new( + "chat-editor", + &draft, + now_sent, + move |_, cx| { + let next = SharedString::from(text.read(cx).text().trim().to_string()); + set(&sent, next, cx); + set(&save, false, cx) + }, + move |_, cx| set(&cancel, false, cx), + ) + .into_any_element() + } else { + div() + .flex() + .items_center() + .gap_3() + .child(div().child(now_sent)) + .child(probe( + "chat-edit-open", + Button::new("chat-edit", "Edit") + .variant(ButtonVariant::Ghost) + .icon(IconName::Pencil) + .on_click(move |_, _, cx| set(&open, true, cx)), + )) + .into_any_element() + }; + section( + "QuoteReply / MessageActions / MessageEditor / BranchNavigator / RegenerateButton / StopGeneratingButton / ContinueButton", + "A quote to answer, the actions on a message, a sent message opened to change (the submit key saves, Escape cancels), the versions an answer has, and the buttons that regenerate, stop and continue.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_4() + .child(QuoteReply::new("chat-quote", "Assistant", "A lift blends a color toward white. Half a lift sits midway between the color and white.").on_remove(|_, _| log::info!("gallery: quote removed"))) + .child(body) + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + MessageActions::new("chat-actions", SENT) + .on_edit(|_, _| log::info!("gallery: edit")) + .on_regenerate(|_, _| log::info!("gallery: regenerate")) + .rating(now_rating, move |next, _, cx| set(&rating, next, cx)), + ) + .child(BranchNavigator::new("chat-branch", now_branch, 3, move |next, _, cx| set(&branch, next, cx))), + ) + .child( + div() + .flex() + .gap_2() + .child(regenerate_button("chat-regenerate").on_click(|_, _, _| log::info!("gallery: regenerate"))) + .child(stop_button("chat-stop").on_click(|_, _, _| log::info!("gallery: stop"))) + .child(continue_button("chat-continue").on_click(|_, _, _| log::info!("gallery: continue"))), + ), + ) +} + +pub fn status(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let until = *keep( + "chat-limit", + || Instant::now() + Duration::from_secs(222), + window, + cx, + ) + .read(cx); + let picked = keep( + "chat-reasons", + || vec![SharedString::from("Too long")], + window, + cx, + ); + let now_picked = picked.read(cx).clone(); + let note = field("chat-note", "", "Anything else? (optional)", window, cx); + section( + "ErrorMessage / RetryMessage / RateLimitNotice / FeedbackForm", + "An answer that failed and a way to try again; a limit reached, counting down to when it lifts; and after a bad mark, what went wrong.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_4() + .child( + MessageBubble::new(Role::Error) + .avatar(MessageAvatar::new("chat-error-mark", Role::Error, "Assistant")) + .child(ErrorMessage::new("chat-error", "The answer stopped halfway").detail("The connection closed before the end. Nothing was lost.").on_retry(|_, _| log::info!("gallery: retry"))), + ) + .child(RateLimitNotice::new("chat-limit", until).on_upgrade(|_, _| log::info!("gallery: raise limit"))) + .child( + FeedbackForm::new( + "chat-feedback", + ["Wrong", "Unclear", "Too long", "Missed the point"], + now_picked, + ¬e, + move |next, _, cx| set(&picked, next.to_vec(), cx), + |_, _| log::info!("gallery: feedback sent"), + ) + .on_cancel(|_, _| log::info!("gallery: feedback cancelled")), + ), + ) +} + +const REASONING: &str = "The reader asks what a lift does. Start from the definition: a blend toward white. Then give the two themes, since they lift by different amounts, and say why: a dark ground makes saturated color look louder, so it needs less of it."; + +pub fn thinking(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + section( + "ThinkingBlock / ReasoningPanel / ThinkingIndicator / ThinkingDuration", + "The reasoning behind an answer, folded under how long it took; open, its steps read in a quiet column. While the assistant works, a word says what it is doing.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_4() + .child(probe( + "chat-thinking", + div().w(px(560.)).child(ThinkingBlock::new("chat-thought", REASONING, false, Duration::from_secs(12))), + )) + .child(ThinkingBlock::new("chat-thinking-now", "Weighing how dark themes change", true, Duration::from_secs(3))) + .child(ThinkingIndicator::new("chat-searching").label("Searching the web")) + .child(ThinkingDuration::new(Duration::from_secs(75))), + ) +} diff --git a/examples/gallery/pages/chat/mod.rs b/examples/gallery/pages/chat/mod.rs new file mode 100644 index 0000000..0245e09 --- /dev/null +++ b/examples/gallery/pages/chat/mod.rs @@ -0,0 +1,45 @@ +mod blocks; +mod controls; +mod talk; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 23, + slug: "chat", + title: "AI Chat", + summary: "A conversation with an assistant: messages that stream in, code and media inside them, and what can be done with each.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("chat-thinking", 12.0, 10.0), + Step::UpAt("chat-thinking", 12.0, 10.0), + Step::Wait(300), + Step::Shot("reasoning"), + Step::DownAt("chat-thinking", 12.0, 10.0), + Step::UpAt("chat-thinking", 12.0, 10.0), + Step::DownAt("chat-edit-open", 20.0, 12.0), + Step::UpAt("chat-edit-open", 20.0, 12.0), + Step::Wait(300), + Step::Shot("editing"), + Step::Key("escape"), + Step::Wait(200), +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(talk::conversation(window, cx)) + .child(talk::streaming(window, cx)) + .child(blocks::code(window, cx)) + .child(blocks::rich(window, cx)) + .child(blocks::media(window, cx)) + .child(controls::actions(window, cx)) + .child(controls::status(window, cx)) + .child(controls::thinking(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/chat/talk.rs b/examples/gallery/pages/chat/talk.rs new file mode 100644 index 0000000..5743759 --- /dev/null +++ b/examples/gallery/pages/chat/talk.rs @@ -0,0 +1,224 @@ +use std::time::Duration; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + chat::{ + ChatContainer, DateSeparator, MessageActions, MessageAvatar, MessageBubble, MessageFooter, + MessageHeader, MessageList, Role, StreamingCursor, StreamingMarkdown, stop_button, + }, + primitives::IconName, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, ElementId, IntoElement, ParentElement, Styled, Task, Window, div, px}; +use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone}; + +use crate::{ + probe::probe, + ui::{keep, section}, +}; + +/// One row of the demo conversation. +#[derive(Clone)] +enum Row { + Day(Date), + Note(&'static str), + Said { + role: Role, + name: &'static str, + minutes_ago: i64, + text: &'static str, + live: bool, + }, +} + +const ANSWER: &str = "A **lift** blends a color toward white. Half a lift sits midway between the color and white; a full lift is white itself.\n\n- In light, accents take half a lift.\n- In dark, they take more, so they read with the same weight."; + +const LIVE: &str = "The lift $t$ mixes a channel toward one:\n\n$$c' = c + t\\,(1 - c)$$\n\nIn code, for each channel:\n\n```rust\nfn lift(c: f32, t: f32) -> f32 {\n c + t * (1.0 - c)\n}\n```\n\nApplied to every"; + +fn rows(today: Date) -> Vec { + let yesterday = today.yesterday().expect("the day before exists"); + vec![ + Row::Day(yesterday), + Row::Said { + role: Role::User, + name: "Ada Park", + minutes_ago: 1500, + text: "What does a lift do to a color?", + live: false, + }, + Row::Said { + role: Role::Assistant, + name: "Assistant", + minutes_ago: 1499, + text: ANSWER, + live: false, + }, + Row::Day(today), + Row::Note("Grace Lin joined the conversation"), + Row::Said { + role: Role::User, + name: "Ada Park", + minutes_ago: 2, + text: "Show me the math, and the code.", + live: false, + }, + Row::Said { + role: Role::Assistant, + name: "Assistant", + minutes_ago: 1, + text: LIVE, + live: true, + }, + ] +} + +fn row(ix: usize, row: &Row, today: Date, now: Timestamp) -> gpui::AnyElement { + let key = |what: &str| (ElementId::from("chat-row"), format!("{what}-{ix}")); + let body = match row { + Row::Day(day) => return DateSeparator::new(*day, today).into_any_element(), + Row::Note(text) => MessageBubble::new(Role::System).child(*text), + Row::Said { + role, + name, + minutes_ago, + text, + live, + } => { + let header = MessageHeader::new(key("header"), *name).at(now - minutes_ago.minutes()); + let header = if *role == Role::Assistant { + header.model("Ely Large") + } else { + header + }; + let bubble = MessageBubble::new(*role) + .avatar(MessageAvatar::new(key("avatar"), *role, *name)) + .header(header) + .child(StreamingMarkdown::new(key("text"), *text, *live)); + match (role, live) { + (Role::Assistant, false) => bubble.footer( + MessageFooter::new().fact("412 tokens").fact("2.1 s").child( + MessageActions::new(key("actions"), *text) + .on_regenerate(|_, _| log::info!("gallery: regenerate")) + .rating(Some(true), |next, _, _| { + log::info!("gallery: rated {next:?}") + }), + ), + ), + (Role::Assistant, true) => bubble.footer(MessageFooter::new().child( + stop_button(key("stop")).on_click(|_, _, _| log::info!("gallery: stop")), + )), + _ => bubble, + } + } + }; + div().px_6().py_3().child(body).into_any_element() +} + +pub fn conversation(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let today = Timestamp::now().to_zoned(TimeZone::system()).date(); + let now = Timestamp::now(); + let all = rows(today); + let count = all.len(); + let theme = cx.theme(); + section( + "ChatContainer / MessageList / MessageBubble / MessageAvatar / MessageHeader / MessageFooter / DateSeparator / ScrollToBottomButton", + "A conversation held at its newest message and drawn only near the view. Yours sit on the right in a quiet bubble; the assistant's read as prose beside its mark. Scroll up and it stays put, with a way back down that counts what arrived.", + cx, + ) + .child(probe( + "chat-conversation", + div() + .w(px(760.)) + .h(px(600.)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child(ChatContainer::new(MessageList::new("chat-messages", count, move |ix, _, _| { + row(ix, &all[ix], today, now) + }))), + )) +} + +/// The demo stream: how much of it has arrived, whether more is coming, and the task that feeds it. +struct Stream { + arrived: usize, + live: bool, + _feed: Option>, +} + +pub fn streaming(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let stream = keep( + "chat-stream", + || Stream { + arrived: ANSWER.len(), + live: false, + _feed: None, + }, + window, + cx, + ); + let (arrived, live) = (stream.read(cx).arrived, stream.read(cx).live); + let text = &ANSWER[..arrived]; + let theme = cx.theme(); + let replay = stream.clone(); + section( + "StreamingText / StreamingMarkdown / StreamingCursor", + "Text as it arrives: what came in shows at a steady pace and catches up when far behind, a caret where more will come; markdown takes shape as its lines land. The cursor alone waits for the first words.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_3() + .child( + div() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .min_h(px(140.)) + .child(if arrived == 0 { + StreamingCursor::new("chat-cursor").into_any_element() + } else { + StreamingMarkdown::new("chat-streamed", text.to_string(), live).into_any_element() + }), + ) + .child( + div().flex().child( + Button::new("chat-replay", "Replay the stream") + .variant(ButtonVariant::Secondary) + .icon(IconName::RefreshCw) + .on_click(move |_, window, cx| { + let feeder = replay.downgrade(); + let feed = window.spawn(cx, async move |cx| { + cx.background_executor().timer(Duration::from_millis(600)).await; + loop { + cx.background_executor().timer(Duration::from_millis(45)).await; + let more = feeder.update(cx, |stream, cx| { + let next = (stream.arrived + 7).min(ANSWER.len()); + stream.arrived = (next..=ANSWER.len()) + .find(|at| ANSWER.is_char_boundary(*at)) + .expect("the text ends on a boundary"); + stream.live = stream.arrived < ANSWER.len(); + cx.notify(); + stream.live + }); + if !matches!(more, Ok(true)) { + break; + } + } + }); + replay.update(cx, |stream, cx| { + stream.arrived = 0; + stream.live = true; + stream._feed = Some(feed); + cx.notify(); + }); + }), + ), + ), + ) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index c53d57a..e049cff 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,5 +1,6 @@ mod buttons; mod charts; +mod chat; mod collab; mod data; mod debug; @@ -59,6 +60,7 @@ pub const ALL: &[Page] = &[ debug::PAGE, documents::PAGE, collab::PAGE, + chat::PAGE, theme::PAGE, ]; diff --git a/src/chat/actions.rs b/src/chat/actions.rs new file mode 100644 index 0000000..0c10dd7 --- /dev/null +++ b/src/chat/actions.rs @@ -0,0 +1,377 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, Focusable, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant, CopyButton, IconButton, ToggleButton, ToggleItem}, + forms::{Input, Pick, Run, Submit, TextInput}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::{Ellipsis, tabular}, +}; + +type OnRate = Rc, &mut Window, &mut App)>; + +/// Asks for another answer to the same message. +pub fn regenerate_button(id: impl Into) -> Button { + Button::new(id, "Regenerate") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .icon(IconName::RefreshCw) +} + +/// Stops an answer while it streams. +pub fn stop_button(id: impl Into) -> Button { + Button::new(id, "Stop") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::CircleStop) +} + +/// Picks an answer up where it was cut off. +pub fn continue_button(id: impl Into) -> Button { + Button::new(id, "Continue") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::StepForward) +} + +/// A message quoted in a reply: a rule, who wrote it and the start of what they said; in the composer, a way to take it off. +#[derive(IntoElement)] +pub struct QuoteReply { + id: ElementId, + author: SharedString, + text: SharedString, + on_remove: Option, +} + +impl QuoteReply { + pub fn new( + id: impl Into, + author: impl Into, + text: impl Into, + ) -> Self { + Self { + id: id.into(), + author: author.into(), + text: text.into(), + on_remove: None, + } + } + + pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for QuoteReply { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let line = self.text.lines().next().unwrap_or_default().to_string(); + div() + .w_full() + .flex() + .items_center() + .gap_2() + .pl_3() + .py_1() + .border_l_2() + .border_color(colors.border_strong) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(self.author), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(Ellipsis::new(line)), + ), + ) + .children(self.on_remove.map(|remove| { + IconButton::new((self.id.clone(), "remove"), IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Remove quote") + .on_click(move |_, window, cx| remove(window, cx)) + })) + } +} + +/// What can be done with a message, in a quiet row: copy, edit, regenerate, and a good or bad mark; each shows once its handler is set, copy always. +#[derive(IntoElement)] +pub struct MessageActions { + id: ElementId, + text: SharedString, + rating: Option, + on_edit: Option, + on_regenerate: Option, + on_rate: Option, +} + +impl MessageActions { + /// `text` is what copy takes. + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + rating: None, + on_edit: None, + on_regenerate: None, + on_rate: None, + } + } + + pub fn on_edit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_edit = Some(Rc::new(handler)); + self + } + + pub fn on_regenerate(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_regenerate = Some(Rc::new(handler)); + self + } + + /// The mark given, good or bad, and what a press asks: the other mark, or none to take it back. + pub fn rating( + mut self, + rating: Option, + on_rate: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.rating = rating; + self.on_rate = Some(Rc::new(on_rate)); + self + } +} + +impl RenderOnce for MessageActions { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let button = |key: &str, icon: IconName, tip: &'static str, run: Option| { + run.map(|run| { + IconButton::new((self.id.clone(), key.to_string()), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .on_click(move |_, window, cx| run(window, cx)) + }) + }; + let mark = |good: bool| { + self.on_rate.clone().map(|rate| { + let on = self.rating == Some(good); + let (key, icon, tip) = if good { + ("good", IconName::ThumbsUp, "Good answer") + } else { + ("bad", IconName::ThumbsDown, "Bad answer") + }; + ToggleButton::new( + (self.id.clone(), key), + ToggleItem::new(key).icon(icon).tooltip(tip), + on, + ) + .size(ControlSize::Sm) + .on_toggle(move |now, window, cx| { + let next = now.then_some(good); + log::info!("message actions: rated {next:?}"); + rate(next, window, cx) + }) + }) + }; + div() + .flex() + .items_center() + .gap_0p5() + .child(CopyButton::new( + (self.id.clone(), "copy"), + self.text.clone(), + )) + .children(button( + "edit", + IconName::Pencil, + "Edit", + self.on_edit.clone(), + )) + .children(button( + "regenerate", + IconName::RefreshCw, + "Regenerate", + self.on_regenerate.clone(), + )) + .children(mark(true)) + .children(mark(false)) + } +} + +/// A sent message opened to change: the field, focused as it opens, then cancel and save. Escape cancels; the submit key saves once the words changed. +#[derive(IntoElement)] +pub struct MessageEditor { + id: ElementId, + field: Entity, + original: SharedString, + on_save: Run, + on_cancel: Run, +} + +impl MessageEditor { + /// `field` holds the words being changed, `original` what was sent. + pub fn new( + id: impl Into, + field: &Entity, + original: impl Into, + on_save: impl Fn(&mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + field: field.clone(), + original: original.into(), + on_save: Rc::new(on_save), + on_cancel: Rc::new(on_cancel), + } + } +} + +impl RenderOnce for MessageEditor { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let opened = window.use_keyed_state((self.id.clone(), "opened"), cx, |_, _| false); + if !*opened.read(cx) { + log::info!("message editor: takes focus"); + window.focus(&self.field.focus_handle(cx)); + opened.update(cx, |opened, _| *opened = true); + } + let text = self.field.read(cx).text().trim().to_string(); + let changed = !text.is_empty() && text != self.original.trim(); + let (save, submit, cancel, escape) = ( + self.on_save.clone(), + self.on_save, + self.on_cancel.clone(), + self.on_cancel, + ); + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .capture_action(move |_: &Submit, window, cx| { + cx.stop_propagation(); + if changed { + log::info!("message editor: saved"); + submit(window, cx); + } + }) + .on_key_down(move |event, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + log::info!("message editor: cancelled"); + escape(window, cx); + } + }) + .child(Input::new(&self.field)) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + Button::new((self.id.clone(), "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| cancel(window, cx)), + ) + .child( + Button::new((self.id.clone(), "save"), "Save") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .shortcut("secondary-enter") + .disabled(!changed) + .on_click(move |_, window, cx| { + log::info!("message editor: saved"); + save(window, cx) + }), + ), + ) + } +} + +/// Which version of a message shows, among all it has: back, where it stands, forward. +#[derive(IntoElement)] +pub struct BranchNavigator { + id: ElementId, + current: usize, + total: usize, + on_change: Pick, +} + +impl BranchNavigator { + pub fn new( + id: impl Into, + current: usize, + total: usize, + on_change: impl Fn(usize, &mut Window, &mut App) + 'static, + ) -> Self { + assert!(current < total, "version {current} of {total}"); + Self { + id: id.into(), + current, + total, + on_change: Rc::new(on_change), + } + } +} + +impl RenderOnce for BranchNavigator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (current, total) = (self.current, self.total); + let step = |key: &str, icon: IconName, tip: &'static str, to: Option| { + let change = self.on_change.clone(); + IconButton::new((self.id.clone(), key.to_string()), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .disabled(to.is_none()) + .when_some(to, |button, to| { + button.on_click(move |_, window, cx| { + log::info!("branch navigator: version {to}"); + change(to, window, cx) + }) + }) + }; + div() + .flex() + .items_center() + .child(step( + "back", + IconName::ChevronLeft, + "Previous version", + current.checked_sub(1), + )) + .child( + tabular( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted), + ) + .child(format!("{} / {total}", current + 1)), + ) + .child(step( + "forward", + IconName::ChevronRight, + "Next version", + (current + 1 < total).then_some(current + 1), + )) + } +} diff --git a/src/chat/code.rs b/src/chat/code.rs new file mode 100644 index 0000000..69ac36e --- /dev/null +++ b/src/chat/code.rs @@ -0,0 +1,192 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, HighlightStyle, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, + relative, +}; + +use crate::{ + buttons::{Button, ButtonVariant, CopyButton, IconButton}, + editor::code_colors, + forms::Run, + primitives::{Disclosure, IconName}, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, +}; + +/// Lines a block shows before it folds. +const FOLD_AFTER: usize = 14; + +/// Where `code` folds after `lines`: the byte end of the lines kept, when there are more. +pub(crate) fn fold_at(code: &str, lines: usize) -> Option { + code.match_indices('\n') + .nth(lines.checked_sub(1)?) + .map(|(at, _)| at) + .filter(|at| code[at + 1..].contains(|c: char| !c.is_whitespace())) +} + +/// Code in a message: its language, a copy button, and the owner's download, run and apply; long code folds to a few lines until opened. +#[derive(IntoElement)] +pub struct CodeBlock { + id: ElementId, + code: SharedString, + language: Option, + on_download: Option, + on_run: Option, + on_apply: Option, +} + +impl CodeBlock { + pub fn new(id: impl Into, code: impl Into) -> Self { + Self { + id: id.into(), + code: code.into(), + language: None, + on_download: None, + on_run: None, + on_apply: None, + } + } + + pub fn language(mut self, language: impl Into) -> Self { + self.language = Some(language.into()); + self + } + + pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_download = Some(Rc::new(handler)); + self + } + + pub fn on_run(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_run = Some(Rc::new(handler)); + self + } + + /// Puts the code where it belongs, such as into the open file. + pub fn on_apply(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_apply = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CodeBlock { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let opened = *open.read(cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let code = self.code.trim_end_matches('\n').to_string(); + let fold = fold_at(&code, FOLD_AFTER).filter(|_| !opened); + let hidden = fold.map(|at| code[at..].lines().count() - 1); + let shown = fold.map_or(code.as_str(), |at| &code[..at]).to_string(); + let styles: Vec<(Range, HighlightStyle)> = code_colors(&shown, cx); + let action = |key: &str, icon: IconName, tip: &'static str, run: Option| { + run.map(|run| { + IconButton::new((self.id.clone(), key.to_string()), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .on_click(move |_, window, cx| run(window, cx)) + }) + }; + let lines = code.lines().count(); + let toggle = open.clone(); + div() + .w_full() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.sunken) + .child( + div() + .flex() + .items_center() + .gap_1() + .pl_3() + .pr_1() + .py_0p5() + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(self.language.clone().unwrap_or_else(|| "code".into())), + ) + .children(action("run", IconName::Play, "Run", self.on_run)) + .children(action("apply", IconName::Check, "Apply", self.on_apply)) + .children(action( + "download", + IconName::Download, + "Download", + self.on_download, + )) + .child(CopyButton::new((self.id.clone(), "copy"), code.clone())), + ) + .child( + div() + .id((self.id.clone(), "code")) + .overflow_x_scroll() + .px_3() + .py_2() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .line_height(relative(1.55)) + .text_color(colors.syntax.variable) + .whitespace_nowrap() + .child(StyledText::new(shown).with_highlights(styles)), + ) + .when(lines > FOLD_AFTER, |block| { + block.child( + div() + .flex() + .items_center() + .gap_1() + .px_1() + .py_0p5() + .border_t_1() + .border_color(colors.border) + .child( + Button::new( + (self.id.clone(), "fold"), + match hidden { + Some(hidden) => format!("Show {hidden} more lines"), + None => "Show less".to_string(), + }, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + toggle.update(cx, |open, cx| { + *open = !*open; + log::info!("code block: open {open}"); + cx.notify(); + }) + }), + ) + .child(Disclosure::new((self.id.clone(), "chevron"), opened)), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::fold_at; + + #[test] + fn long_code_folds_after_its_lines() { + assert_eq!(fold_at("a\nb\nc", 2), Some(3)); + assert_eq!(fold_at("a\nb", 2), None, "two lines fit"); + assert_eq!( + fold_at("a\nb\n\n", 2), + None, + "blank lines past the fold do not count" + ); + assert_eq!(fold_at("a", 0), None); + } +} diff --git a/src/chat/list.rs b/src/chat/list.rs new file mode 100644 index 0000000..0f99b8e --- /dev/null +++ b/src/chat/list.rs @@ -0,0 +1,262 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Context, ElementId, IntoElement, ListAlignment, ListOffset, ListState, + ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, div, list, prelude::*, +}; +use jiff::civil::Date; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Run, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Elevation, TextSize}, +}; + +type RenderItem = Rc AnyElement>; + +/// A list's place: its gpui list, how many messages it knew, whether the reader scrolled away from the newest, and how many came since. +struct Feed { + list: ListState, + count: usize, + away: bool, + fresh: usize, +} + +/// How many messages arrived while the reader was away, from `before` to `now`; none once they are back. +pub(crate) fn arrived(before: usize, now: usize, away: bool, fresh: usize) -> usize { + match (away, now >= before) { + (true, true) => fresh + (now - before), + _ => 0, + } +} + +/// Messages down a column drawn only near the view, held at the newest: scrolled up, it stays put as messages arrive and offers a way back down with how many are new. Give each conversation its own id. +#[derive(IntoElement)] +pub struct MessageList { + id: ElementId, + count: usize, + render: RenderItem, +} + +impl MessageList { + /// `render` draws message `ix` of `count`. + pub fn new( + id: impl Into, + count: usize, + render: impl Fn(usize, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + id: id.into(), + count, + render: Rc::new(render), + } + } +} + +impl RenderOnce for MessageList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.count; + let overdraw = cx.theme().list_overdraw().to_pixels(window.rem_size()); + let feed = window.use_keyed_state( + (self.id.clone(), "feed"), + cx, + move |_, cx: &mut Context| { + let list = ListState::new(count, ListAlignment::Bottom, overdraw); + let owner = cx.entity().downgrade(); + list.set_scroll_handler(move |event, _, cx| { + let away = event.is_scrolled; + if let Err(error) = owner.update(cx, |feed, cx| { + if feed.away != away { + log::info!("message list: away {away}"); + feed.away = away; + feed.fresh = 0; + cx.notify(); + } + }) { + log::error!("message list: its place is gone: {error:#}"); + } + }); + Feed { + list, + count, + away: false, + fresh: 0, + } + }, + ); + if feed.read(cx).count != count { + feed.update(cx, |feed, _| { + if count >= feed.count { + feed.list.splice(feed.count..feed.count, count - feed.count); + } else { + feed.list.splice(count..feed.count, 0); + } + feed.fresh = arrived(feed.count, count, feed.away, feed.fresh); + feed.count = count; + }); + } + let (state, away, fresh) = { + let feed = feed.read(cx); + (feed.list.clone(), feed.away, feed.fresh) + }; + let render = self.render; + let down = feed.clone(); + div() + .relative() + .size_full() + .child(list(state, move |ix, window, cx| render(ix, window, cx)).size_full()) + .when(away, |view| { + view.child( + div() + .absolute() + .bottom_4() + .left_0() + .right_0() + .flex() + .justify_center() + .child(ScrollToBottomButton::new( + (self.id.clone(), "down"), + fresh, + move |_, cx| { + down.update(cx, |feed, cx| { + log::info!("message list: back to the newest"); + let end = feed.list.item_count(); + feed.list.scroll_to(ListOffset { + item_ix: end, + offset_in_item: Pixels::ZERO, + }); + feed.away = false; + feed.fresh = 0; + cx.notify(); + }) + }, + )), + ) + }) + } +} + +/// A way back to the newest message, floating over the list, with how many came in while away. +#[derive(IntoElement)] +pub struct ScrollToBottomButton { + id: ElementId, + fresh: usize, + on_press: Run, +} + +impl ScrollToBottomButton { + pub fn new( + id: impl Into, + fresh: usize, + on_press: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + fresh, + on_press: Rc::new(on_press), + } + } +} + +impl RenderOnce for ScrollToBottomButton { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let label: SharedString = match self.fresh { + 0 => "Latest".into(), + 1 => "1 new message".into(), + n => format!("{n} new messages").into(), + }; + let press = self.on_press; + div() + .rounded_full() + .shadow(cx.theme().elevation(Elevation::Floating)) + .child( + Button::new(self.id, label) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::ArrowDown) + .on_click(move |_, window, cx| press(window, cx)), + ) + } +} + +/// A day's line between messages: Today, Yesterday, or the date. +#[derive(IntoElement)] +pub struct DateSeparator { + day: Date, + today: Date, +} + +impl DateSeparator { + /// `today` names the reader's day, so tests and captures hold still. + pub fn new(day: Date, today: Date) -> Self { + Self { day, today } + } +} + +/// The words for `day` seen from `today`. +pub(crate) fn day_name(day: Date, today: Date) -> String { + if day == today { + "Today".to_string() + } else if today.yesterday().is_ok_and(|yesterday| yesterday == day) { + "Yesterday".to_string() + } else if day.year() == today.year() { + day.strftime("%A, %B %-d").to_string() + } else { + day.strftime("%B %-d, %Y").to_string() + } +} + +impl RenderOnce for DateSeparator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rule = || div().flex_1().h_px().bg(colors.border); + div() + .w_full() + .flex() + .items_center() + .gap_3() + .py_2() + .child(rule()) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(day_name(self.day, self.today)), + ) + .child(rule()) + } +} + +#[cfg(test)] +mod tests { + use jiff::civil::date; + + use super::*; + + #[test] + fn days_read_as_today_yesterday_or_a_date() { + let today = date(2026, 9, 26); + assert_eq!(day_name(today, today), "Today"); + assert_eq!(day_name(date(2026, 9, 25), today), "Yesterday"); + assert_eq!(day_name(date(2026, 9, 1), today), "Tuesday, September 1"); + assert_eq!(day_name(date(2025, 12, 31), today), "December 31, 2025"); + } + + #[test] + fn new_messages_count_only_while_away() { + assert_eq!(arrived(3, 5, true, 0), 2); + assert_eq!(arrived(5, 6, true, 2), 3); + assert_eq!( + arrived(5, 6, false, 0), + 0, + "at the newest, nothing to count" + ); + assert_eq!( + arrived(6, 2, true, 3), + 0, + "fewer messages start the count over" + ); + } +} diff --git a/src/chat/media.rs b/src/chat/media.rs new file mode 100644 index 0000000..738e9ee --- /dev/null +++ b/src/chat/media.rs @@ -0,0 +1,425 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ObjectFit, + ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + div, prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + documents::source, + forms::{Pick, Run}, + primitives::{Icon, IconName, Image}, + theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize}, + typography::{Ellipsis, format::file_size}, +}; + +/// A picture's box: its own shape, no wider than `widest`. +pub(crate) fn fitted(width: f32, height: f32, widest: f32) -> (f32, f32) { + assert!( + width > 0.0 && height > 0.0, + "a picture of {width}x{height} has no shape" + ); + let shown = width.min(widest); + (shown, shown * height / width) +} + +/// A picture in a message, in its own shape up to the column's width, a caption under it; a press asks the owner to open it large. +#[derive(IntoElement)] +pub struct ImageMessage { + id: ElementId, + source: SharedString, + size: (f32, f32), + caption: Option, + on_open: Option, +} + +impl ImageMessage { + /// `source` is a file or a web address; `width` and `height` its pixels. + pub fn new( + id: impl Into, + source: impl Into, + width: f32, + height: f32, + ) -> Self { + Self { + id: id.into(), + source: source.into(), + size: (width, height), + caption: None, + on_open: None, + } + } + + pub fn caption(mut self, caption: impl Into) -> Self { + self.caption = Some(caption.into()); + self + } + + pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ImageMessage { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let widest = f32::from(theme.prose_width().to_pixels(window.rem_size())); + let (width, height) = fitted(self.size.0, self.size.1, widest); + let open = self.on_open; + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .id(self.id.clone()) + .relative() + .w(Pixels::from(width)) + .h(Pixels::from(height)) + .when_some(open, |picture, open| { + picture + .cursor_pointer() + .on_click(move |_, window, cx| open(window, cx)) + }) + .child( + Image::new((self.id.clone(), "picture"), source(&self.source)) + .fit(ObjectFit::Contain) + .size_full(), + ), + ) + .children(self.caption.map(|caption| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(caption) + })) + } +} + +/// Pictures sent together as a grid: one fills it, two share a row, three set one large beside two, four make a square; past four, the last tile counts the rest. A press asks to open one. +#[derive(IntoElement)] +pub struct ImageGrid { + id: ElementId, + sources: Vec, + on_open: Option, +} + +impl ImageGrid { + pub fn new( + id: impl Into, + sources: impl IntoIterator>, + ) -> Self { + let sources: Vec = sources.into_iter().map(Into::into).collect(); + assert!(!sources.is_empty(), "a grid holds a picture"); + Self { + id: id.into(), + sources, + on_open: None, + } + } + + /// Gets the index of the picture pressed. + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ImageGrid { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let side = theme.prose_width(); + let rest = self.sources.len().saturating_sub(4); + let tile = |ix: usize| -> AnyElement { + let open = self.on_open.clone(); + div() + .id((self.id.clone(), format!("tile-{ix}"))) + .relative() + .flex_1() + .min_w_0() + .h_full() + .bg(colors.sunken) + .when_some(open, |tile, open| { + tile.cursor_pointer() + .on_click(move |_, window, cx| open(ix, window, cx)) + }) + .child( + Image::new( + (self.id.clone(), format!("picture-{ix}")), + source(&self.sources[ix]), + ) + .fit(ObjectFit::Cover) + .size_full(), + ) + .when(ix == 3 && rest > 0, |tile| { + tile.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(colors.media_backdrop.opacity(0.55)) + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.on_media) + .child(format!("+{rest}")), + ) + }) + .into_any_element() + }; + let row = || div().flex().flex_1().min_h_0().gap_0p5(); + let grid = div().w(side).h(side).flex().flex_col().gap_0p5(); + match self.sources.len() { + 1 => grid.child(row().child(tile(0))), + 2 => grid + .h(side * 0.5) + .child(row().child(tile(0)).child(tile(1))), + 3 => grid.child( + div().flex().flex_1().gap_0p5().child(tile(0)).child( + div() + .flex_1() + .flex() + .flex_col() + .gap_0p5() + .child(row().child(tile(1))) + .child(row().child(tile(2))), + ), + ), + _ => grid + .child(row().child(tile(0)).child(tile(1))) + .child(row().child(tile(2)).child(tile(3))), + } + } +} + +/// The icon a file's name suggests. +pub(crate) fn file_icon(name: &str) -> IconName { + let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase()); + match extension.as_deref() { + Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic") => IconName::Image, + Some("pdf" | "md" | "txt" | "doc" | "docx" | "rtf" | "pages") => IconName::FileText, + _ => IconName::File, + } +} + +/// A file in a message: its kind's icon, its name and size, and a way to download it. +#[derive(IntoElement)] +pub struct FileMessage { + id: ElementId, + name: SharedString, + bytes: u64, + on_download: Option, +} + +impl FileMessage { + pub fn new(id: impl Into, name: impl Into, bytes: u64) -> Self { + Self { + id: id.into(), + name: name.into(), + bytes, + on_download: None, + } + } + + pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_download = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FileMessage { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .w(theme.prose_width()) + .flex() + .items_center() + .gap_3() + .p_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .flex_none() + .size(theme.avatar_size(AvatarSize::Md)) + .flex() + .items_center() + .justify_center() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.hover) + .child( + Icon::new(file_icon(&self.name)) + .size(IconSize::Md) + .color(colors.fg_muted), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(self.name.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(file_size(self.bytes, false)), + ), + ) + .children(self.on_download.map(|download| { + IconButton::new((self.id.clone(), "download"), IconName::Download) + .variant(ButtonVariant::Ghost) + .tooltip("Download") + .on_click(move |_, window, cx| download(window, cx)) + })) + } +} + +/// A link unfolded: the site, the page's title and a line about it, and its picture; a press opens it. +#[derive(IntoElement)] +pub struct LinkPreviewCard { + id: ElementId, + site: SharedString, + title: SharedString, + description: Option, + picture: Option, + on_open: Option, +} + +impl LinkPreviewCard { + pub fn new( + id: impl Into, + site: impl Into, + title: impl Into, + ) -> Self { + Self { + id: id.into(), + site: site.into(), + title: title.into(), + description: None, + picture: None, + on_open: None, + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + /// A square picture, a file or a web address. + pub fn picture(mut self, picture: impl Into) -> Self { + self.picture = Some(picture.into()); + self + } + + pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LinkPreviewCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let open = self.on_open; + div() + .id(self.id.clone()) + .w(theme.container_width(ContainerSize::Sm)) + .flex() + .gap_3() + .p_3() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .when_some(open, |card, open| { + card.cursor_pointer() + .hover(|card| card.bg(colors.hover)) + .on_click(move |_, window, cx| open(window, cx)) + }) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_center() + .gap_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child( + Icon::new(IconName::Globe) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child(self.site), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child(self.title), + ) + .children(self.description.map(|text| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(text) + })), + ) + .children(self.picture.map(|picture| { + div() + .flex_none() + .size(theme.avatar_size(AvatarSize::Xl)) + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .child( + Image::new((self.id.clone(), "picture"), source(&picture)) + .fit(ObjectFit::Cover) + .size_full(), + ) + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_picture_keeps_its_shape_within_the_column() { + assert_eq!(fitted(800.0, 600.0, 320.0), (320.0, 240.0)); + assert_eq!( + fitted(200.0, 100.0, 320.0), + (200.0, 100.0), + "small ones stay" + ); + } + + #[test] + fn a_file_name_picks_its_icon() { + assert_eq!(file_icon("lift.PNG"), IconName::Image); + assert_eq!(file_icon("notes.md"), IconName::FileText); + assert_eq!(file_icon("archive"), IconName::File); + } +} diff --git a/src/chat/message.rs b/src/chat/message.rs new file mode 100644 index 0000000..6a039dc --- /dev/null +++ b/src/chat/message.rs @@ -0,0 +1,339 @@ +use std::path::Path; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, relative, +}; +use jiff::Timestamp; +use smallvec::SmallVec; + +use crate::{ + data_display::Avatar, + primitives::{Icon, IconName}, + theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize}, + typography::RelativeTime, +}; + +/// Who a message is from, or what it says about the conversation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + User, + Assistant, + System, + Error, +} + +/// Who wrote a message at a glance: a person's picture or initials, the assistant's mark, or a warning for an error. +#[derive(IntoElement)] +pub struct MessageAvatar { + id: ElementId, + role: Role, + name: SharedString, + picture: Option, +} + +impl MessageAvatar { + pub fn new(id: impl Into, role: Role, name: impl Into) -> Self { + Self { + id: id.into(), + role, + name: name.into(), + picture: None, + } + } + + /// A person's picture, a file or a web address. + pub fn picture(mut self, picture: impl Into) -> Self { + self.picture = Some(picture.into()); + self + } +} + +impl RenderOnce for MessageAvatar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + match self.role { + Role::User | Role::System => { + let avatar = Avatar::new(self.id, self.name).size(AvatarSize::Sm); + match self.picture { + Some(picture) => avatar.image(Path::new(picture.as_ref())), + None => avatar, + } + .into_any_element() + } + Role::Assistant | Role::Error => { + let (icon, tint) = if self.role == Role::Error { + (IconName::TriangleAlert, colors.danger) + } else { + (IconName::Sparkles, colors.accent) + }; + div() + .flex_none() + .size(theme.avatar_size(AvatarSize::Sm)) + .flex() + .items_center() + .justify_center() + .rounded(theme.radius(Radius::Md)) + .bg(tint.opacity(0.12)) + .child(Icon::new(icon).size(IconSize::Sm).color(tint)) + .into_any_element() + } + } + } +} + +/// A message's name line: who, when, and what model answered. +#[derive(IntoElement)] +pub struct MessageHeader { + id: ElementId, + name: SharedString, + at: Option, + model: Option, +} + +impl MessageHeader { + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + at: None, + model: None, + } + } + + pub fn at(mut self, at: Timestamp) -> Self { + self.at = Some(at); + self + } + + pub fn model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } +} + +impl RenderOnce for MessageHeader { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .items_baseline() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(self.name), + ) + .children(self.model.map(|model| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(model) + })) + .children(self.at.map(|at| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(RelativeTime::new((self.id.clone(), "when"), at)) + })) + } +} + +/// A message's last line: quiet facts, such as tokens and time, then its actions. +#[derive(IntoElement, Default)] +pub struct MessageFooter { + facts: Vec, + actions: SmallVec<[AnyElement; 2]>, +} + +impl MessageFooter { + pub fn new() -> Self { + Self::default() + } + + pub fn fact(mut self, fact: impl Into) -> Self { + self.facts.push(fact.into()); + self + } +} + +impl ParentElement for MessageFooter { + fn extend(&mut self, elements: impl IntoIterator) { + self.actions.extend(elements); + } +} + +impl RenderOnce for MessageFooter { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .children(self.actions) + .when(!self.facts.is_empty(), |row| { + row.child(div().child(self.facts.join(" · "))) + }) + } +} + +/// One message: yours in a quiet bubble on the right, the assistant's as prose beside its mark, a system note centered and small, an error beside a warning, its body an `ErrorMessage`. A header, a footer and an avatar are optional. +#[derive(IntoElement)] +pub struct MessageBubble { + role: Role, + avatar: Option, + header: Option, + footer: Option, + body: SmallVec<[AnyElement; 2]>, +} + +impl MessageBubble { + pub fn new(role: Role) -> Self { + Self { + role, + avatar: None, + header: None, + footer: None, + body: SmallVec::new(), + } + } + + pub fn avatar(mut self, avatar: MessageAvatar) -> Self { + self.avatar = Some(avatar); + self + } + + pub fn header(mut self, header: MessageHeader) -> Self { + self.header = Some(header); + self + } + + pub fn footer(mut self, footer: MessageFooter) -> Self { + self.footer = Some(footer); + self + } +} + +impl ParentElement for MessageBubble { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for MessageBubble { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let body = div() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Base)) + .line_height(relative(1.6)) + .children(self.body); + match self.role { + Role::System => div() + .w_full() + .flex() + .justify_center() + .py_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(body.text_size(theme.text_size(TextSize::Xs))) + .into_any_element(), + Role::User => div() + .w_full() + .flex() + .justify_end() + .gap_3() + .child( + div() + .max_w(theme.container_width(ContainerSize::Sm)) + .flex() + .flex_col() + .items_end() + .gap_1() + .children(self.header) + .child( + div() + .px_4() + .py_2p5() + .rounded(theme.radius(Radius::Xl)) + .bg(colors.hover) + .text_color(colors.fg) + .child(body), + ) + .children(self.footer), + ) + .children(self.avatar) + .into_any_element(), + Role::Assistant | Role::Error => div() + .w_full() + .flex() + .gap_3() + .children(self.avatar) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .children(self.header) + .child(body.text_color(colors.fg)) + .children(self.footer), + ) + .into_any_element(), + } + } +} + +/// A conversation's frame: its messages in a column sized for reading, the composer held at the bottom. +#[derive(IntoElement)] +pub struct ChatContainer { + messages: AnyElement, + composer: Option, +} + +impl ChatContainer { + pub fn new(messages: impl IntoElement) -> Self { + Self { + messages: messages.into_any_element(), + composer: None, + } + } + + pub fn composer(mut self, composer: impl IntoElement) -> Self { + self.composer = Some(composer.into_any_element()); + self + } +} + +impl RenderOnce for ChatContainer { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let column = theme.container_width(ContainerSize::Md); + div() + .size_full() + .flex() + .flex_col() + .bg(theme.colors.bg) + .child(div().flex_1().min_h_0().child(self.messages)) + .children(self.composer.map(|composer| { + div() + .flex_none() + .w_full() + .flex() + .justify_center() + .px_4() + .pb_4() + .child(div().w_full().max_w(column).child(composer)) + })) + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs new file mode 100644 index 0000000..4add991 --- /dev/null +++ b/src/chat/mod.rs @@ -0,0 +1,26 @@ +mod actions; +mod code; +mod list; +mod media; +mod message; +mod play; +mod status; +mod stream; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use actions::{ + BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button, + stop_button, +}; +pub use code::CodeBlock; +pub use list::{DateSeparator, MessageList, ScrollToBottomButton}; +pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard}; +pub use message::{ + ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role, +}; +pub use play::{AudioMessage, VideoMessage}; +pub use status::{ + ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator, +}; +pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText}; diff --git a/src/chat/play.rs b/src/chat/play.rs new file mode 100644 index 0000000..50c522b --- /dev/null +++ b/src/chat/play.rs @@ -0,0 +1,300 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + relative, +}; + +use super::media::fitted; +use crate::{ + buttons::{ButtonVariant, IconButton}, + documents::source, + forms::OnFlag, + primitives::{Icon, IconName, Image}, + theme::{ActiveTheme, ContainerSize, ControlSize, IconSize, Radius, TextSize}, + typography::{DurationStyle, format::duration, tabular}, +}; + +type OnSeek = Rc; + +/// A clip's time as it plays: what played and the length, in clock digits. +fn clock(played: Duration, length: Duration) -> String { + format!( + "{} / {}", + duration(played.as_secs(), DurationStyle::Clock), + duration(length.as_secs(), DurationStyle::Clock) + ) +} + +/// A voice note or a song: play and pause, a waveform lit as far as it has played, and the time; a press on the waveform seeks there. The host plays it. +#[derive(IntoElement)] +pub struct AudioMessage { + id: ElementId, + peaks: Rc>, + length: Duration, + played: Duration, + playing: bool, + on_toggle: Option, + on_seek: Option, +} + +impl AudioMessage { + /// `peaks` are the waveform's heights, 0 to 1, left to right. + pub fn new(id: impl Into, peaks: impl Into>, length: Duration) -> Self { + let peaks: Vec = peaks.into(); + assert!(!peaks.is_empty(), "a waveform has peaks"); + assert!( + peaks.iter().all(|peak| (0.0..=1.0).contains(peak)), + "peaks lie within 0 to 1" + ); + Self { + id: id.into(), + peaks: Rc::new(peaks), + length, + played: Duration::ZERO, + playing: false, + on_toggle: None, + on_seek: None, + } + } + + /// How far it has played, and whether it plays now. + pub fn played(mut self, played: Duration, playing: bool) -> Self { + self.played = played.min(self.length); + self.playing = playing; + self + } + + /// Gets true to play, false to pause. + pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } + + /// Gets the share of the clip to seek to. + pub fn on_seek(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self { + self.on_seek = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AudioMessage { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let share = if self.length.is_zero() { + 0.0 + } else { + self.played.as_secs_f32() / self.length.as_secs_f32() + }; + let count = self.peaks.len(); + let playing = self.playing; + let tall = theme.control_height(ControlSize::Md); + div() + .w(theme.prose_width()) + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .rounded_full() + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .children(self.on_toggle.map(|toggle| { + IconButton::new( + (self.id.clone(), "toggle"), + if playing { + IconName::Pause + } else { + IconName::Play + }, + ) + .variant(ButtonVariant::Ghost) + .tooltip(if playing { "Pause" } else { "Play" }) + .on_click(move |_, window, cx| { + log::info!("audio message: playing {}", !playing); + toggle(!playing, window, cx) + }) + })) + .child( + div() + .flex_1() + .h(tall) + .flex() + .items_center() + .gap_px() + .children(self.peaks.iter().enumerate().map(|(ix, peak)| { + let seek = self.on_seek.clone(); + let lit = (ix as f32 + 0.5) / count as f32 <= share; + div() + .id((self.id.clone(), format!("bar-{ix}"))) + .flex_1() + .h_full() + .flex() + .items_center() + .when_some(seek, |bar, seek| { + bar.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + seek((ix as f32 + 0.5) / count as f32, window, cx) + }) + }) + .child( + div() + .w_full() + .h(tall * peak.max(0.08)) + .rounded_full() + .bg(if lit { colors.accent } else { colors.fg_subtle }), + ) + })), + ) + .child( + tabular( + div() + .pr_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted), + ) + .child(clock(self.played, self.length)), + ) + } +} + +/// A video in a message: the frame the host shows, its poster or the playing frame, with play and pause over it and the time. The host decodes and plays it. +#[derive(IntoElement)] +pub struct VideoMessage { + id: ElementId, + frame: SharedString, + size: (f32, f32), + length: Duration, + played: Duration, + playing: bool, + on_toggle: Option, +} + +impl VideoMessage { + /// `frame` is the picture to show, a file or a web address; `width` and `height` the video's pixels. + pub fn new( + id: impl Into, + frame: impl Into, + width: f32, + height: f32, + length: Duration, + ) -> Self { + Self { + id: id.into(), + frame: frame.into(), + size: (width, height), + length, + played: Duration::ZERO, + playing: false, + on_toggle: None, + } + } + + pub fn played(mut self, played: Duration, playing: bool) -> Self { + self.played = played.min(self.length); + self.playing = playing; + self + } + + /// Gets true to play, false to pause. + pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for VideoMessage { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let widest = f32::from( + theme + .container_width(ContainerSize::Sm) + .to_pixels(window.rem_size()), + ); + let (width, height) = fitted(self.size.0, self.size.1, widest); + let share = if self.length.is_zero() { + 0.0 + } else { + self.played.as_secs_f32() / self.length.as_secs_f32() + }; + let playing = self.playing; + let toggle = self.on_toggle.clone(); + div() + .id(self.id.clone()) + .relative() + .w(Pixels::from(width)) + .h(Pixels::from(height)) + .bg(colors.media_backdrop) + .when_some(toggle, |video, toggle| { + video.cursor_pointer().on_click(move |_, window, cx| { + log::info!("video message: playing {}", !playing); + toggle(!playing, window, cx) + }) + }) + .child( + Image::new((self.id.clone(), "frame"), source(&self.frame)) + .fit(ObjectFit::Contain) + .size_full(), + ) + .when(!playing, |video| { + video.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + div() + .size(theme.fab_size()) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(colors.media_backdrop.opacity(0.6)) + .child( + Icon::new(IconName::Play) + .size(IconSize::Lg) + .color(colors.on_media), + ), + ), + ) + }) + .child( + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .flex() + .flex_col() + .child( + div().flex().justify_end().px_2().pb_1().child( + tabular( + div() + .px_1p5() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.media_backdrop.opacity(0.6)) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.on_media), + ) + .child(clock(self.played, self.length)), + ), + ) + .child( + div() + .h(theme.progress_thickness()) + .w_full() + .bg(colors.on_media.opacity(0.25)) + .child(div().h_full().w(relative(share)).bg(colors.on_media)), + ), + ) + } +} diff --git a/src/chat/status.rs b/src/chat/status.rs new file mode 100644 index 0000000..49b99cd --- /dev/null +++ b/src/chat/status.rs @@ -0,0 +1,420 @@ +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, relative, +}; + +use super::stream::StreamingText; +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::{Alert, Countdown}, + forms::{Choice, ChoiceChips, Input, OnValues, Run, TextInput}, + primitives::{Disclosure, Icon, IconName, Severity}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::ShimmerText, +}; + +/// An answer that failed: what went wrong in a line, a detail, and a way to try again. +#[derive(IntoElement)] +pub struct ErrorMessage { + id: ElementId, + title: SharedString, + detail: Option, + on_retry: Option, +} + +impl ErrorMessage { + pub fn new(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + detail: None, + on_retry: None, + } + } + + pub fn detail(mut self, text: impl Into) -> Self { + self.detail = Some(text.into()); + self + } + + pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_retry = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ErrorMessage { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let alert = Alert::new(self.id.clone(), Severity::Danger, self.title); + let alert = match self.detail { + Some(detail) => alert.body(detail), + None => alert, + }; + match self.on_retry { + Some(retry) => alert.action( + Button::new((self.id.clone(), "retry"), "Try again") + .size(ControlSize::Sm) + .icon(IconName::RefreshCw) + .on_click(move |_, window, cx| { + log::info!("error message: retry"); + retry(window, cx) + }), + ), + None => alert, + } + } +} + +/// Out of messages for now: when more come back, counting down, and the owner's way to raise the limit. +#[derive(IntoElement)] +pub struct RateLimitNotice { + id: ElementId, + until: Instant, + on_done: Option, + on_upgrade: Option, +} + +impl RateLimitNotice { + pub fn new(id: impl Into, until: Instant) -> Self { + Self { + id: id.into(), + until, + on_done: None, + on_upgrade: None, + } + } + + /// Runs once the wait is over. + pub fn on_done(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_done = Some(Rc::new(handler)); + self + } + + pub fn on_upgrade(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_upgrade = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RateLimitNotice { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let countdown = Countdown::new((self.id.clone(), "left"), self.until).size(TextSize::Sm); + let countdown = match self.on_done { + Some(done) => countdown.on_done(move |window, cx| done(window, cx)), + None => countdown, + }; + div() + .w_full() + .flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + Icon::new(IconName::Clock) + .size(IconSize::Sm) + .color(colors.warning), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .child( + div() + .text_color(colors.fg) + .child("You've reached the message limit. More in"), + ) + .child(countdown), + ) + .children(self.on_upgrade.map(|upgrade| { + Button::new((self.id.clone(), "upgrade"), "Raise the limit") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| upgrade(window, cx)) + })) + } +} + +/// What went wrong with an answer, after a bad mark: reasons to pick, a note, then send; sending waits for a reason or a note. +#[derive(IntoElement)] +pub struct FeedbackForm { + id: ElementId, + reasons: Vec, + picked: Vec, + note: Entity, + on_pick: OnValues, + on_send: Run, + on_cancel: Option, +} + +impl FeedbackForm { + /// `picked` are the reasons chosen and `on_pick` gets the next set; `note` is the owner's field; `on_send` sends both. + pub fn new( + id: impl Into, + reasons: impl IntoIterator>, + picked: impl IntoIterator>, + note: &Entity, + on_pick: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + reasons: reasons.into_iter().map(Into::into).collect(), + picked: picked.into_iter().map(Into::into).collect(), + note: note.clone(), + on_pick: Rc::new(on_pick), + on_send: Rc::new(on_send), + on_cancel: None, + } + } + + pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FeedbackForm { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let ready = !self.picked.is_empty() || !self.note.read(cx).text().trim().is_empty(); + let (pick, send) = (self.on_pick, self.on_send); + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child("What went wrong?"), + ) + .child( + ChoiceChips::new( + (self.id.clone(), "reasons"), + self.reasons + .iter() + .map(|reason| Choice::new(reason.clone(), reason.clone())), + ) + .multiple() + .selected(self.picked.clone()) + .on_change(move |next, window, cx| pick(next, window, cx)), + ) + .child(Input::new(&self.note)) + .child( + div() + .flex() + .justify_end() + .gap_2() + .children(self.on_cancel.map(|cancel| { + Button::new((self.id.clone(), "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| cancel(window, cx)) + })) + .child( + Button::new((self.id.clone(), "send"), "Send feedback") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .disabled(!ready) + .on_click(move |_, window, cx| { + log::info!("feedback form: sent"); + send(window, cx) + }), + ), + ) + } +} + +/// How long thinking took, in words. +pub(crate) fn thought_for(took: Duration) -> String { + match took.as_secs() { + 0 => "Thought for a moment".to_string(), + 1 => "Thought for 1 second".to_string(), + seconds @ 2..60 => format!("Thought for {seconds} seconds"), + seconds => format!("Thought for {}m {}s", seconds / 60, seconds % 60), + } +} + +/// How long thinking took: a quiet line, such as “Thought for 12 seconds”. +#[derive(IntoElement)] +pub struct ThinkingDuration { + took: Duration, +} + +impl ThinkingDuration { + pub fn new(took: Duration) -> Self { + Self { took } + } +} + +impl RenderOnce for ThinkingDuration { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .text_color(cx.theme().colors.fg_muted) + .child(thought_for(self.took)) + } +} + +/// Work under way before an answer: a word, such as Thinking, with light sweeping across it; still under reduced motion. +#[derive(IntoElement)] +pub struct ThinkingIndicator { + id: ElementId, + label: SharedString, +} + +impl ThinkingIndicator { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + label: "Thinking".into(), + } + } + + /// What the work is, such as Searching the web. + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } +} + +impl RenderOnce for ThinkingIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + div() + .flex() + .items_center() + .gap_1p5() + .child( + Icon::new(IconName::Sparkles) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(ShimmerText::new(self.id, self.label)) + } +} + +/// The reasoning behind an answer, folded under how long it took; open, its steps read in a quiet column. While thinking, it says so and the reasoning streams in. +#[derive(IntoElement)] +pub struct ThinkingBlock { + id: ElementId, + reasoning: SharedString, + thinking: bool, + took: Duration, +} + +impl ThinkingBlock { + /// `took` is how long thinking ran, or has run while `thinking`. + pub fn new( + id: impl Into, + reasoning: impl Into, + thinking: bool, + took: Duration, + ) -> Self { + Self { + id: id.into(), + reasoning: reasoning.into(), + thinking, + took, + } + } +} + +impl RenderOnce for ThinkingBlock { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let opened = *open.read(cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let header: gpui::AnyElement = if self.thinking { + ThinkingIndicator::new((self.id.clone(), "thinking")).into_any_element() + } else { + ThinkingDuration::new(self.took).into_any_element() + }; + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .id((self.id.clone(), "header")) + .flex() + .items_center() + .gap_1() + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + open.update(cx, |open, cx| { + *open = !*open; + log::info!("thinking block: open {open}"); + cx.notify(); + }) + }) + .child(header) + .child( + Disclosure::new((self.id.clone(), "chevron"), opened).size(IconSize::Sm), + ), + ) + .when(opened, |block| { + block.child( + div() + .pl_3() + .border_l_2() + .border_color(colors.border) + .text_color(colors.fg_muted) + .line_height(relative(1.6)) + .child(StreamingText::new( + (self.id.clone(), "reasoning"), + self.reasoning, + self.thinking, + )), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::thought_for; + use std::time::Duration; + + #[test] + fn thinking_time_reads_in_words() { + assert_eq!( + thought_for(Duration::from_millis(400)), + "Thought for a moment" + ); + assert_eq!(thought_for(Duration::from_secs(1)), "Thought for 1 second"); + assert_eq!( + thought_for(Duration::from_secs(12)), + "Thought for 12 seconds" + ); + assert_eq!(thought_for(Duration::from_secs(75)), "Thought for 1m 15s"); + } +} diff --git a/src/chat/stream.rs b/src/chat/stream.rs new file mode 100644 index 0000000..d833eda --- /dev/null +++ b/src/chat/stream.rs @@ -0,0 +1,199 @@ +use std::time::{Duration, Instant}; + +use gpui::{ + App, ElementId, HighlightStyle, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + StyledText, Window, div, +}; +use unicode_segmentation::UnicodeSegmentation; + +use super::code::CodeBlock; +use crate::{ + documents::MarkdownRenderer, + motion::Blink, + theme::{ActiveTheme, Radius, TextSize}, +}; + +/// Graphemes a stream shows per second when it keeps up. +const PACE: f32 = 90.0; +/// The longest a backlog takes to show, however much arrived at once. +const CATCH_UP: Duration = Duration::from_millis(350); +/// The mark where more will come, set inline after the text. +const CARET: &str = "▍"; + +/// How far a reveal has come, in graphemes with the fraction carried, and when it last moved. +struct Reveal { + shown: f32, + at: Instant, +} + +/// Graphemes shown after `elapsed`, from `shown` toward `total`: a steady pace, faster when far behind. +pub(crate) fn advanced(shown: f32, total: usize, elapsed: Duration) -> f32 { + let behind = total as f32 - shown; + if behind <= 0.0 { + return total as f32; + } + let rate = PACE.max(behind / CATCH_UP.as_secs_f32()); + (shown + rate * elapsed.as_secs_f32()).min(total as f32) +} + +/// Where the first `count` graphemes of `text` end, in bytes. +pub(crate) fn grapheme_end(text: &str, count: usize) -> usize { + text.grapheme_indices(true) + .nth(count) + .map_or(text.len(), |(at, _)| at) +} + +/// How much of `text` shows now, in bytes: a live stream reveals from its first frame, a finished one shows whole. Asks for frames until caught up; under reduced motion, all at once. +fn revealed(id: &ElementId, text: &str, live: bool, window: &mut Window, cx: &mut App) -> usize { + let total = text.graphemes(true).count(); + let now = cx.background_executor().now(); + let state = window.use_keyed_state((id.clone(), "reveal"), cx, |_, _| Reveal { + shown: if live { 0.0 } else { total as f32 }, + at: now, + }); + let (shown, at) = (state.read(cx).shown.min(total as f32), state.read(cx).at); + let shown = if cx.theme().reduced_motion { + total as f32 + } else { + advanced(shown, total, now.saturating_duration_since(at)) + }; + state.update(cx, |reveal, _| *reveal = Reveal { shown, at: now }); + if (shown as usize) < total { + window.request_animation_frame(); + } + grapheme_end(text, shown as usize) +} + +/// Text as it arrives: what came in shows at a steady pace, catching up when far behind, with a caret while more may come. +#[derive(IntoElement)] +pub struct StreamingText { + id: ElementId, + text: SharedString, + live: bool, +} + +impl StreamingText { + /// `live` while more may arrive; a finished message shows whole. + pub fn new(id: impl Into, text: impl Into, live: bool) -> Self { + Self { + id: id.into(), + text: text.into(), + live, + } + } +} + +impl RenderOnce for StreamingText { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let end = revealed(&self.id, &self.text, self.live, window, cx); + let caret = cx.theme().colors.fg_muted; + let shown = if self.live { + format!("{}{CARET}", &self.text[..end]) + } else { + self.text[..end].to_string() + }; + let styles = self + .live + .then(|| { + ( + end..end + CARET.len(), + HighlightStyle { + color: Some(caret), + ..HighlightStyle::default() + }, + ) + }) + .into_iter(); + StyledText::new(shown).with_highlights(styles) + } +} + +/// Where more will come before any text has: a small block that blinks while it waits, still under reduced motion. +#[derive(IntoElement)] +pub struct StreamingCursor { + id: ElementId, +} + +impl StreamingCursor { + pub fn new(id: impl Into) -> Self { + Self { id: id.into() } + } +} + +impl RenderOnce for StreamingCursor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let size = theme.text_size(TextSize::Base); + Blink::new(self.id).child( + div() + .w(size * 0.5) + .h(size * 1.2) + .rounded(theme.radius(Radius::Sm)) + .bg(theme.colors.fg_muted), + ) + } +} + +/// Markdown as it arrives, revealed like streaming text: headings, lists, tables and math take shape as their lines land, code in code blocks. +#[derive(IntoElement)] +pub struct StreamingMarkdown { + id: ElementId, + source: SharedString, + live: bool, +} + +impl StreamingMarkdown { + /// `live` while more may arrive; a finished message shows whole. + pub fn new(id: impl Into, source: impl Into, live: bool) -> Self { + Self { + id: id.into(), + source: source.into(), + live, + } + } +} + +impl RenderOnce for StreamingMarkdown { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let end = revealed(&self.id, &self.source, self.live, window, cx); + let shown = if self.live { + format!("{}{CARET}", &self.source[..end]) + } else { + self.source[..end].to_string() + }; + MarkdownRenderer::new((self.id.clone(), "text"), shown).code(|id, language, code| { + let block = CodeBlock::new(id, code); + match language { + Some(language) => block.language(language), + None => block, + } + .into_any_element() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reveal_keeps_pace_and_catches_up() { + let tick = Duration::from_millis(100); + assert_eq!( + advanced(0.0, 5, tick), + 5.0, + "a short line shows within a tick" + ); + let long = advanced(0.0, 1000, tick); + assert!(long > PACE * 0.1, "far behind, it speeds up: {long}"); + assert!(long < 1000.0, "but still reveals: {long}"); + assert_eq!(advanced(12.0, 10, tick), 10.0, "shorter text clamps"); + } + + #[test] + fn a_count_of_graphemes_ends_on_a_boundary() { + assert_eq!(grapheme_end("héllo", 2), 3); + assert_eq!(grapheme_end("👩‍🚀 ok", 1), "👩‍🚀".len()); + assert_eq!(grapheme_end("ok", 9), 2); + } +} diff --git a/src/chat/tests.rs b/src/chat/tests.rs new file mode 100644 index 0000000..90eff66 --- /dev/null +++ b/src/chat/tests.rs @@ -0,0 +1,164 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AppContext as _, Context, Entity, Focusable, IntoElement, Modifiers, ParentElement, Render, + ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, + div, point, px, +}; + +use super::{MessageEditor, MessageList}; +use crate::{forms, forms::TextInput, theme::Theme}; + +type Seen = Rc>>; + +/// A conversation of tall messages, noting which ones each frame drew. +struct Talk { + count: usize, + seen: Seen, +} + +impl Render for Talk { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let seen = self.seen.clone(); + seen.borrow_mut().clear(); + div() + .size_full() + .child(MessageList::new("talk", self.count, move |ix, _, _| { + seen.borrow_mut().push(ix); + div().h(px(80.0)).w_full().into_any_element() + })) + } +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +fn newest(seen: &Seen) -> usize { + *seen.borrow().iter().max().expect("a frame draws messages") +} + +#[gpui::test] +fn the_list_follows_the_newest_until_the_reader_scrolls_away(cx: &mut TestAppContext) { + cx.update(Theme::init); + let seen = Seen::default(); + let noted = seen.clone(); + let (talk, cx) = cx.add_window_view(move |_, _| Talk { + count: 40, + seen: noted, + }); + settle(cx); + assert_eq!(newest(&seen), 39, "it opens at the newest"); + talk.update(cx, |talk, cx| { + talk.count = 42; + cx.notify(); + }); + settle(cx); + assert_eq!(newest(&seen), 41, "at the newest, it follows"); + let middle = cx.update(|window, _| { + let size = window.viewport_size(); + point(size.width / 2.0, size.height / 2.0) + }); + cx.simulate_event(ScrollWheelEvent { + position: middle, + delta: ScrollDelta::Pixels(point(px(0.0), px(1200.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + settle(cx); + let away = newest(&seen); + assert!(away < 41, "scrolled up to {away}"); + talk.update(cx, |talk, cx| { + talk.count = 45; + cx.notify(); + }); + settle(cx); + assert_eq!(newest(&seen), away, "away, it holds its place"); + let button = cx.update(|window, _| { + let size = window.viewport_size(); + point(size.width / 2.0, size.height - px(28.0)) + }); + cx.simulate_click(button, Modifiers::none()); + settle(cx); + assert_eq!(newest(&seen), 44, "the button goes back to the newest"); +} + +/// A sent message open to change, and what the reader asked. +struct Edit { + field: Entity, + saved: usize, + cancelled: usize, +} + +impl Render for Edit { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (save, cancel) = (cx.entity(), cx.entity()); + div().size_full().child(MessageEditor::new( + "edit", + &self.field, + "Lift blends toward white.", + move |_, cx| save.update(cx, |edit, _| edit.saved += 1), + move |_, cx| cancel.update(cx, |edit, _| edit.cancelled += 1), + )) + } +} + +#[gpui::test] +fn an_edit_saves_only_a_change_and_escape_cancels(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (edit, cx) = cx.add_window_view(|window, cx| Edit { + field: cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text("Lift blends toward white.", cx); + input + }), + saved: 0, + cancelled: 0, + }); + cx.update(|window, cx| { + window.activate_window(); + let field = edit.read(cx).field.focus_handle(cx); + window.focus(&field); + }); + settle(cx); + cx.simulate_keystrokes("cmd-enter"); + settle(cx); + assert_eq!( + edit.read_with(cx, |edit, _| edit.saved), + 0, + "nothing changed" + ); + cx.simulate_input(" Gently."); + settle(cx); + cx.simulate_keystrokes("cmd-enter"); + settle(cx); + assert_eq!(edit.read_with(cx, |edit, _| edit.saved), 1); + cx.simulate_keystrokes("escape"); + assert_eq!(edit.read_with(cx, |edit, _| edit.cancelled), 1); +} + +#[gpui::test] +fn the_editor_takes_focus_as_it_opens(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (edit, cx) = cx.add_window_view(|window, cx| Edit { + field: cx.new(|cx| TextInput::new(window, cx)), + saved: 0, + cancelled: 0, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + cx.simulate_keystrokes("escape"); + assert_eq!( + edit.read_with(cx, |edit, _| edit.cancelled), + 1, + "Escape reaches the editor" + ); +} diff --git a/src/documents/render.rs b/src/documents/render.rs index 4113614..078f169 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -17,6 +17,7 @@ use crate::{ }; type OnLink = Rc; +type DrawCode = Rc, SharedString) -> AnyElement>; /// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host. #[derive(IntoElement)] @@ -26,6 +27,7 @@ pub struct MarkdownRenderer { on_link: Option, size: TextSize, sections: Option>, + code: Option, } impl MarkdownRenderer { @@ -36,6 +38,7 @@ impl MarkdownRenderer { on_link: None, size: TextSize::Base, sections: None, + code: None, } } @@ -51,6 +54,15 @@ impl MarkdownRenderer { self } + /// Draws code blocks with the owner's element, given an id, the language and the code. + pub fn code( + mut self, + draw: impl Fn(ElementId, Option, SharedString) -> AnyElement + 'static, + ) -> Self { + self.code = Some(Rc::new(draw)); + self + } + /// Takes link presses instead of the browser. pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { self.on_link = Some(Rc::new(handler)); @@ -65,6 +77,7 @@ struct Draw<'a> { next: std::cell::Cell, sections: &'a Option>, headings: std::cell::Cell, + code: &'a Option, } impl Draw<'_> { @@ -192,6 +205,13 @@ impl Draw<'_> { .children(self.nodes(inner, cx)) .into_any_element(), Node::Code { language, text } => { + if let Some(draw) = self.code { + return draw( + self.key("code"), + language.clone().map(Into::into), + text.clone().into(), + ); + } let styles = code_colors(text, cx); div() .flex() @@ -406,6 +426,7 @@ impl RenderOnce for MarkdownRenderer { next: std::cell::Cell::new(0), sections: &self.sections, headings: std::cell::Cell::new(0), + code: &self.code, }; div() .flex() diff --git a/src/lib.rs b/src/lib.rs index 6dc9afd..d4d5279 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod assets; pub mod buttons; pub mod charts; +pub mod chat; pub mod collab; pub mod data_display; pub mod debug; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index a0de7e9..51b16aa 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -76,39 +76,39 @@ ## T23a AI Chat · Messages — `src/chat` -- [ ] ChatContainer -- [ ] MessageList — virtualized, stick to bottom, new-message hint -- [ ] MessageBubble — user, assistant, system, error -- [ ] MessageAvatar -- [ ] MessageHeader -- [ ] MessageFooter -- [ ] StreamingText -- [ ] StreamingMarkdown -- [ ] StreamingCursor -- [ ] CodeBlock — language, copy, download, run, fold, apply -- [ ] MathBlock → `typography::Latex` -- [ ] TableBlock → `documents::MarkdownRenderer` -- [ ] MermaidRenderer → `documents::MermaidBlock` -- [ ] ImageMessage / ImageGrid -- [ ] FileMessage -- [ ] AudioMessage -- [ ] VideoMessage — host frames -- [ ] LinkPreviewCard -- [ ] QuoteReply -- [ ] MessageActions -- [ ] MessageEditor -- [ ] BranchNavigator -- [ ] RegenerateButton -- [ ] StopGeneratingButton -- [ ] ContinueButton -- [ ] ErrorMessage / RetryMessage -- [ ] RateLimitNotice -- [ ] FeedbackForm -- [ ] ScrollToBottomButton -- [ ] DateSeparator -- [ ] ThinkingBlock / ReasoningPanel -- [ ] ThinkingIndicator -- [ ] ThinkingDuration +- [x] ChatContainer +- [x] MessageList — virtualized, stick to bottom, new-message hint +- [x] MessageBubble — user, assistant, system, error +- [x] MessageAvatar +- [x] MessageHeader +- [x] MessageFooter +- [x] StreamingText +- [x] StreamingMarkdown +- [x] StreamingCursor +- [x] CodeBlock — language, copy, download, run, fold, apply +- [x] MathBlock → `typography::Latex` +- [x] TableBlock → `documents::MarkdownRenderer` +- [x] MermaidRenderer → the host draws the SVG; `chat::ImageMessage` shows it +- [x] ImageMessage / ImageGrid +- [x] FileMessage +- [x] AudioMessage +- [x] VideoMessage — host frames +- [x] LinkPreviewCard +- [x] QuoteReply +- [x] MessageActions +- [x] MessageEditor +- [x] BranchNavigator +- [x] RegenerateButton → `chat::regenerate_button` +- [x] StopGeneratingButton → `chat::stop_button` +- [x] ContinueButton → `chat::continue_button` +- [x] ErrorMessage / RetryMessage → `chat::ErrorMessage`, over `feedback::Alert` +- [x] RateLimitNotice +- [x] FeedbackForm +- [x] ScrollToBottomButton +- [x] DateSeparator +- [x] ThinkingBlock / ReasoningPanel → `chat::ThinkingBlock` +- [x] ThinkingIndicator +- [x] ThinkingDuration ## T23b AI Chat · Citations