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
This commit is contained in:
1 parent
03f6ceb583
commit
c0afce2507
21 files changed
+3376
-34
No files matched your search
@@ -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.
|
- 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`).
|
- 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 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 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<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. 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.
|
- 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.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T21b Documents · Reading
|
- [x] T21b Documents · Reading
|
||||||
- [x] T21c Documents · Knowledge
|
- [x] T21c Documents · Knowledge
|
||||||
- [x] T22 Collaboration
|
- [x] T22 Collaboration
|
||||||
- [ ] T23a AI Chat · Messages
|
- [x] T23a AI Chat · Messages
|
||||||
- [ ] T23b AI Chat · Citations
|
- [ ] T23b AI Chat · Citations
|
||||||
- [ ] T23c AI Chat · Input
|
- [ ] T23c AI Chat · Input
|
||||||
- [ ] T23d AI Chat · Conversations
|
- [ ] T23d AI Chat · Conversations
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="560" height="160" viewBox="0 0 560 160">
|
||||||
|
<rect width="560" height="160" rx="10" fill="#FBFAF7"/>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8C8A85"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<g font-family="Helvetica, Arial, sans-serif" font-size="14" text-anchor="middle">
|
||||||
|
<rect x="28" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
|
||||||
|
<text x="94" y="85" fill="#2B2A28">Base color</text>
|
||||||
|
<rect x="214" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
|
||||||
|
<text x="280" y="78" fill="#2B2A28">Lift</text>
|
||||||
|
<text x="280" y="96" fill="#8C8A85" font-size="12">t = 0.5 or 0.62</text>
|
||||||
|
<rect x="400" y="54" width="132" height="52" rx="8" fill="#FFFFFF" stroke="#C9C6BF"/>
|
||||||
|
<text x="466" y="85" fill="#2B2A28">Theme accent</text>
|
||||||
|
<line x1="160" y1="80" x2="210" y2="80" stroke="#8C8A85" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
|
<line x1="346" y1="80" x2="396" y2="80" stroke="#8C8A85" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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<f32> = (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")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TextInput> {
|
||||||
|
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::<bool>, 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))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<Row> {
|
||||||
|
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<Task<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod buttons;
|
mod buttons;
|
||||||
mod charts;
|
mod charts;
|
||||||
|
mod chat;
|
||||||
mod collab;
|
mod collab;
|
||||||
mod data;
|
mod data;
|
||||||
mod debug;
|
mod debug;
|
||||||
@@ -59,6 +60,7 @@ pub const ALL: &[Page] = &[
|
|||||||
debug::PAGE,
|
debug::PAGE,
|
||||||
documents::PAGE,
|
documents::PAGE,
|
||||||
collab::PAGE,
|
collab::PAGE,
|
||||||
|
chat::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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<dyn Fn(Option<bool>, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// Asks for another answer to the same message.
|
||||||
|
pub fn regenerate_button(id: impl Into<ElementId>) -> 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<ElementId>) -> 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<ElementId>) -> 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<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuoteReply {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
author: impl Into<SharedString>,
|
||||||
|
text: impl Into<SharedString>,
|
||||||
|
) -> 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<bool>,
|
||||||
|
on_edit: Option<Run>,
|
||||||
|
on_regenerate: Option<Run>,
|
||||||
|
on_rate: Option<OnRate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageActions {
|
||||||
|
/// `text` is what copy takes.
|
||||||
|
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> 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<bool>,
|
||||||
|
on_rate: impl Fn(Option<bool>, &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>| {
|
||||||
|
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<TextInput>,
|
||||||
|
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<ElementId>,
|
||||||
|
field: &Entity<TextInput>,
|
||||||
|
original: impl Into<SharedString>,
|
||||||
|
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<ElementId>,
|
||||||
|
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<usize>| {
|
||||||
|
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),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<usize> {
|
||||||
|
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<SharedString>,
|
||||||
|
on_download: Option<Run>,
|
||||||
|
on_run: Option<Run>,
|
||||||
|
on_apply: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeBlock {
|
||||||
|
pub fn new(id: impl Into<ElementId>, code: impl Into<SharedString>) -> 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<SharedString>) -> 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<usize>, HighlightStyle)> = code_colors(&shown, cx);
|
||||||
|
let action = |key: &str, icon: IconName, tip: &'static str, run: Option<Run>| {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Fn(usize, &mut Window, &mut App) -> 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<ElementId>,
|
||||||
|
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<Feed>| {
|
||||||
|
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<ElementId>,
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SharedString>,
|
||||||
|
on_open: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageMessage {
|
||||||
|
/// `source` is a file or a web address; `width` and `height` its pixels.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
source: impl Into<SharedString>,
|
||||||
|
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<SharedString>) -> 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<SharedString>,
|
||||||
|
on_open: Option<Pick>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageGrid {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
sources: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
) -> Self {
|
||||||
|
let sources: Vec<SharedString> = 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<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileMessage {
|
||||||
|
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, 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<SharedString>,
|
||||||
|
picture: Option<SharedString>,
|
||||||
|
on_open: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkPreviewCard {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
site: impl Into<SharedString>,
|
||||||
|
title: impl Into<SharedString>,
|
||||||
|
) -> 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<SharedString>) -> Self {
|
||||||
|
self.description = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A square picture, a file or a web address.
|
||||||
|
pub fn picture(mut self, picture: impl Into<SharedString>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageAvatar {
|
||||||
|
pub fn new(id: impl Into<ElementId>, role: Role, name: impl Into<SharedString>) -> 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<SharedString>) -> 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<Timestamp>,
|
||||||
|
model: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageHeader {
|
||||||
|
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> 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<SharedString>) -> 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<SharedString>,
|
||||||
|
actions: SmallVec<[AnyElement; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageFooter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fact(mut self, fact: impl Into<SharedString>) -> Self {
|
||||||
|
self.facts.push(fact.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParentElement for MessageFooter {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||||
|
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<MessageAvatar>,
|
||||||
|
header: Option<MessageHeader>,
|
||||||
|
footer: Option<MessageFooter>,
|
||||||
|
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<Item = AnyElement>) {
|
||||||
|
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<AnyElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<dyn Fn(f32, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// 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<Vec<f32>>,
|
||||||
|
length: Duration,
|
||||||
|
played: Duration,
|
||||||
|
playing: bool,
|
||||||
|
on_toggle: Option<OnFlag>,
|
||||||
|
on_seek: Option<OnSeek>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioMessage {
|
||||||
|
/// `peaks` are the waveform's heights, 0 to 1, left to right.
|
||||||
|
pub fn new(id: impl Into<ElementId>, peaks: impl Into<Vec<f32>>, length: Duration) -> Self {
|
||||||
|
let peaks: Vec<f32> = 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<OnFlag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ElementId>,
|
||||||
|
frame: impl Into<SharedString>,
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SharedString>,
|
||||||
|
on_retry: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorMessage {
|
||||||
|
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
title: title.into(),
|
||||||
|
detail: None,
|
||||||
|
on_retry: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(mut self, text: impl Into<SharedString>) -> 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<Run>,
|
||||||
|
on_upgrade: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimitNotice {
|
||||||
|
pub fn new(id: impl Into<ElementId>, 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<SharedString>,
|
||||||
|
picked: Vec<SharedString>,
|
||||||
|
note: Entity<TextInput>,
|
||||||
|
on_pick: OnValues,
|
||||||
|
on_send: Run,
|
||||||
|
on_cancel: Option<Run>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ElementId>,
|
||||||
|
reasons: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
picked: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
note: &Entity<TextInput>,
|
||||||
|
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<ElementId>) -> 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<SharedString>) -> 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<ElementId>,
|
||||||
|
reasoning: impl Into<SharedString>,
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ElementId>, text: impl Into<SharedString>, 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<ElementId>) -> 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<ElementId>, source: impl Into<SharedString>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RefCell<Vec<usize>>>;
|
||||||
|
|
||||||
|
/// 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<Self>) -> 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<TextInput>,
|
||||||
|
saved: usize,
|
||||||
|
cancelled: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Edit {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||||
|
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, 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.
|
/// 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)]
|
#[derive(IntoElement)]
|
||||||
@@ -26,6 +27,7 @@ pub struct MarkdownRenderer {
|
|||||||
on_link: Option<OnLink>,
|
on_link: Option<OnLink>,
|
||||||
size: TextSize,
|
size: TextSize,
|
||||||
sections: Option<Entity<Sections>>,
|
sections: Option<Entity<Sections>>,
|
||||||
|
code: Option<DrawCode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownRenderer {
|
impl MarkdownRenderer {
|
||||||
@@ -36,6 +38,7 @@ impl MarkdownRenderer {
|
|||||||
on_link: None,
|
on_link: None,
|
||||||
size: TextSize::Base,
|
size: TextSize::Base,
|
||||||
sections: None,
|
sections: None,
|
||||||
|
code: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,15 @@ impl MarkdownRenderer {
|
|||||||
self
|
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>, SharedString) -> AnyElement + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.code = Some(Rc::new(draw));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Takes link presses instead of the browser.
|
/// Takes link presses instead of the browser.
|
||||||
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
|
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
|
||||||
self.on_link = Some(Rc::new(handler));
|
self.on_link = Some(Rc::new(handler));
|
||||||
@@ -65,6 +77,7 @@ struct Draw<'a> {
|
|||||||
next: std::cell::Cell<usize>,
|
next: std::cell::Cell<usize>,
|
||||||
sections: &'a Option<Entity<Sections>>,
|
sections: &'a Option<Entity<Sections>>,
|
||||||
headings: std::cell::Cell<usize>,
|
headings: std::cell::Cell<usize>,
|
||||||
|
code: &'a Option<DrawCode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Draw<'_> {
|
impl Draw<'_> {
|
||||||
@@ -192,6 +205,13 @@ impl Draw<'_> {
|
|||||||
.children(self.nodes(inner, cx))
|
.children(self.nodes(inner, cx))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
Node::Code { language, text } => {
|
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);
|
let styles = code_colors(text, cx);
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -406,6 +426,7 @@ impl RenderOnce for MarkdownRenderer {
|
|||||||
next: std::cell::Cell::new(0),
|
next: std::cell::Cell::new(0),
|
||||||
sections: &self.sections,
|
sections: &self.sections,
|
||||||
headings: std::cell::Cell::new(0),
|
headings: std::cell::Cell::new(0),
|
||||||
|
code: &self.code,
|
||||||
};
|
};
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod assets;
|
|||||||
|
|
||||||
pub mod buttons;
|
pub mod buttons;
|
||||||
pub mod charts;
|
pub mod charts;
|
||||||
|
pub mod chat;
|
||||||
pub mod collab;
|
pub mod collab;
|
||||||
pub mod data_display;
|
pub mod data_display;
|
||||||
pub mod debug;
|
pub mod debug;
|
||||||
|
|||||||
+33
-33
@@ -76,39 +76,39 @@
|
|||||||
|
|
||||||
## T23a AI Chat · Messages — `src/chat`
|
## T23a AI Chat · Messages — `src/chat`
|
||||||
|
|
||||||
- [ ] ChatContainer
|
- [x] ChatContainer
|
||||||
- [ ] MessageList — virtualized, stick to bottom, new-message hint
|
- [x] MessageList — virtualized, stick to bottom, new-message hint
|
||||||
- [ ] MessageBubble — user, assistant, system, error
|
- [x] MessageBubble — user, assistant, system, error
|
||||||
- [ ] MessageAvatar
|
- [x] MessageAvatar
|
||||||
- [ ] MessageHeader
|
- [x] MessageHeader
|
||||||
- [ ] MessageFooter
|
- [x] MessageFooter
|
||||||
- [ ] StreamingText
|
- [x] StreamingText
|
||||||
- [ ] StreamingMarkdown
|
- [x] StreamingMarkdown
|
||||||
- [ ] StreamingCursor
|
- [x] StreamingCursor
|
||||||
- [ ] CodeBlock — language, copy, download, run, fold, apply
|
- [x] CodeBlock — language, copy, download, run, fold, apply
|
||||||
- [ ] MathBlock → `typography::Latex`
|
- [x] MathBlock → `typography::Latex`
|
||||||
- [ ] TableBlock → `documents::MarkdownRenderer`
|
- [x] TableBlock → `documents::MarkdownRenderer`
|
||||||
- [ ] MermaidRenderer → `documents::MermaidBlock`
|
- [x] MermaidRenderer → the host draws the SVG; `chat::ImageMessage` shows it
|
||||||
- [ ] ImageMessage / ImageGrid
|
- [x] ImageMessage / ImageGrid
|
||||||
- [ ] FileMessage
|
- [x] FileMessage
|
||||||
- [ ] AudioMessage
|
- [x] AudioMessage
|
||||||
- [ ] VideoMessage — host frames
|
- [x] VideoMessage — host frames
|
||||||
- [ ] LinkPreviewCard
|
- [x] LinkPreviewCard
|
||||||
- [ ] QuoteReply
|
- [x] QuoteReply
|
||||||
- [ ] MessageActions
|
- [x] MessageActions
|
||||||
- [ ] MessageEditor
|
- [x] MessageEditor
|
||||||
- [ ] BranchNavigator
|
- [x] BranchNavigator
|
||||||
- [ ] RegenerateButton
|
- [x] RegenerateButton → `chat::regenerate_button`
|
||||||
- [ ] StopGeneratingButton
|
- [x] StopGeneratingButton → `chat::stop_button`
|
||||||
- [ ] ContinueButton
|
- [x] ContinueButton → `chat::continue_button`
|
||||||
- [ ] ErrorMessage / RetryMessage
|
- [x] ErrorMessage / RetryMessage → `chat::ErrorMessage`, over `feedback::Alert`
|
||||||
- [ ] RateLimitNotice
|
- [x] RateLimitNotice
|
||||||
- [ ] FeedbackForm
|
- [x] FeedbackForm
|
||||||
- [ ] ScrollToBottomButton
|
- [x] ScrollToBottomButton
|
||||||
- [ ] DateSeparator
|
- [x] DateSeparator
|
||||||
- [ ] ThinkingBlock / ReasoningPanel
|
- [x] ThinkingBlock / ReasoningPanel → `chat::ThinkingBlock`
|
||||||
- [ ] ThinkingIndicator
|
- [x] ThinkingIndicator
|
||||||
- [ ] ThinkingDuration
|
- [x] ThinkingDuration
|
||||||
|
|
||||||
## T23b AI Chat · Citations
|
## T23b AI Chat · Citations
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user