From 471c7bc1883e9325edd4c00182ed789c4133f792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 06:07:42 -0400 Subject: [PATCH] T23b: AI chat, citations - CitationBadge: a numbered pill after a claim, its source on hover, a press opens it; a Tab stop - SourceCard and SourceList: the sources folded under their count and sites, each a card numbered as cited; the fold opens from the keyboard - SearchProgress: steps waiting, working and done - WebResultCard and DocumentChunkPreview with matched words washed - gallery: citation and sources states --- TASKS.md | 2 +- examples/gallery/pages/chat/cite.rs | 123 ++++++++++++ examples/gallery/pages/chat/mod.rs | 12 ++ src/chat/cite.rs | 272 ++++++++++++++++++++++++++ src/chat/mod.rs | 4 + src/chat/search.rs | 289 ++++++++++++++++++++++++++++ src/chat/tests.rs | 88 ++++++++- tasks/ch21-30.md | 12 +- 8 files changed, 794 insertions(+), 8 deletions(-) create mode 100644 examples/gallery/pages/chat/cite.rs create mode 100644 src/chat/cite.rs create mode 100644 src/chat/search.rs diff --git a/TASKS.md b/TASKS.md index 5183959..9433e4e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -73,7 +73,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T21c Documents · Knowledge - [x] T22 Collaboration - [x] T23a AI Chat · Messages -- [ ] T23b AI Chat · Citations +- [x] T23b AI Chat · Citations - [ ] T23c AI Chat · Input - [ ] T23d AI Chat · Conversations - [ ] T23e AI Chat · Welcome diff --git a/examples/gallery/pages/chat/cite.rs b/examples/gallery/pages/chat/cite.rs new file mode 100644 index 0000000..4348e50 --- /dev/null +++ b/examples/gallery/pages/chat/cite.rs @@ -0,0 +1,123 @@ +use ely_gpui_component::chat::{ + CitationBadge, DocumentChunkPreview, SearchProgress, Source, SourceList, StepState, + WebResultCard, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use crate::{probe::probe, ui::section}; + +fn sources() -> Vec { + [ + ( + "example.com", + "Color, in light and dark", + "https://example.com/color/light-and-dark", + "How one accent reads the same on paper and on charcoal.", + ), + ( + "example.org", + "Gamma and the eye", + "https://example.org/notes/gamma", + "Why even steps in gamma space look even to the eye.", + ), + ( + "example.net", + "Designing dark themes", + "https://example.net/dark-themes", + "Lift surfaces rather than shade them; depth reads as light.", + ), + ] + .into_iter() + .map(|(site, title, url, snippet)| Source { + site: site.into(), + title: title.into(), + url: url.into(), + snippet: Some(snippet.into()), + }) + .collect() +} + +pub fn citations(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let all = sources(); + let cite = |ix: usize| { + CitationBadge::new(("chat-cite", ix), ix + 1, all[ix].clone()) + .on_open(move |_, _| log::info!("gallery: open source {ix}")) + }; + section( + "CitationBadge / InlineCitation / SourceCard / SourceList / SourcesPanel", + "Where an answer's words came from: a numbered pill after the claim, its source a rest of the pointer away; the sources folded under the answer, each a card.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .child("A lift blends a color toward white, in gamma space.") + .child(probe("chat-cite-1", cite(1))) + .child("Dark themes lift further.") + .child(cite(0)) + .child(cite(2)), + ) + .child(probe( + "chat-sources", + div().w(px(560.)).child( + SourceList::new("chat-sources", all.clone()) + .on_open(|ix, _, _| log::info!("gallery: open source {ix}")), + ), + )), + ) +} + +const PASSAGE: &str = "Accents in dark carry more lift than in light, because a saturated color on charcoal looks louder than the same color on paper."; + +pub fn search(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let all = sources(); + let louder = PASSAGE + .find("looks louder") + .expect("the passage holds its match"); + let more = PASSAGE + .find("more lift") + .expect("the passage holds its match"); + section( + "SearchProgress / WebResultCard / DocumentChunkPreview", + "A search as it runs, step by step; a result as the web gives it; and a passage found in a document, the words that matched washed.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_4() + .child(SearchProgress::new( + "chat-search", + [ + ("Searching for “lift in dark themes”", StepState::Done), + ("Reading 3 sources", StepState::Working), + ("Writing the answer", StepState::Waiting), + ], + )) + .child( + WebResultCard::new("chat-result", all[2].clone()) + .date("Sep 2026") + .on_open(|_, _| log::info!("gallery: open result")), + ) + .child( + DocumentChunkPreview::new( + "lift-notes.pdf", + "Page 1 · Dark is not inverted", + PASSAGE, + vec![more..more + "more lift".len(), louder..louder + "looks louder".len()], + ) + .score(0.86), + ), + ) +} diff --git a/examples/gallery/pages/chat/mod.rs b/examples/gallery/pages/chat/mod.rs index 0245e09..a98c758 100644 --- a/examples/gallery/pages/chat/mod.rs +++ b/examples/gallery/pages/chat/mod.rs @@ -1,4 +1,5 @@ mod blocks; +mod cite; mod controls; mod talk; @@ -29,6 +30,15 @@ const SCRIPT: &[Step] = &[ Step::Shot("editing"), Step::Key("escape"), Step::Wait(200), + Step::HoverAt("chat-cite-1", 8.0, 8.0), + Step::Wait(700), + Step::Shot("citation"), + Step::DownAt("chat-sources", 20.0, 10.0), + Step::UpAt("chat-sources", 20.0, 10.0), + Step::Wait(300), + Step::Shot("sources"), + Step::DownAt("chat-sources", 20.0, 10.0), + Step::UpAt("chat-sources", 20.0, 10.0), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -41,5 +51,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(controls::actions(window, cx)) .child(controls::status(window, cx)) .child(controls::thinking(window, cx)) + .child(cite::citations(window, cx)) + .child(cite::search(window, cx)) .into_any_element() } diff --git a/src/chat/cite.rs b/src/chat/cite.rs new file mode 100644 index 0000000..db28d02 --- /dev/null +++ b/src/chat/cite.rs @@ -0,0 +1,272 @@ +use std::rc::Rc; + +use gpui::{ + App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + transparent_black, +}; + +use crate::{ + forms::{Pick, Run}, + overlays::HoverCard, + primitives::{Disclosure, FocusRing, Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{Ellipsis, tabular}, +}; + +/// Where an answer's words came from: the site, the page's title, its address and a line from it. +#[derive(Clone, Debug, PartialEq)] +pub struct Source { + pub site: SharedString, + pub title: SharedString, + pub url: SharedString, + pub snippet: Option, +} + +/// A source at a glance: its site, title and a line from it; a press opens it. +#[derive(IntoElement)] +pub struct SourceCard { + id: ElementId, + number: Option, + source: Source, + on_open: Option, +} + +impl SourceCard { + pub fn new(id: impl Into, source: Source) -> Self { + Self { + id: id.into(), + number: None, + source, + on_open: None, + } + } + + /// The number the answer cites it by. + pub fn number(mut self, number: usize) -> Self { + self.number = Some(number); + self + } + + pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +/// A source's lines: the number it is cited by, its site, its title and a line from it. +fn lines(number: Option, source: &Source, cx: &App) -> Div { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .children(number.map(|number| { + tabular( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted), + ) + .child(format!("{number}")) + })) + .child( + Icon::new(IconName::Globe) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child(source.site.clone()), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(source.title.clone())), + ) + .children(source.snippet.clone().map(|snippet| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(snippet) + })) +} + +impl RenderOnce for SourceCard { + 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_full() + .p_2p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .when_some(open, |card, open| { + card.cursor_pointer() + .hover(|card| card.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| { + log::info!("source card: open"); + open(window, cx) + }) + }) + .child(lines(self.number, &self.source, cx)) + } +} + +/// A citation in running text: a small numbered pill; resting on it shows its source, a press opens it. +#[derive(IntoElement)] +pub struct CitationBadge { + id: ElementId, + number: usize, + source: Source, + on_open: Option, +} + +impl CitationBadge { + pub fn new(id: impl Into, number: usize, source: Source) -> Self { + Self { + id: id.into(), + number, + source, + on_open: None, + } + } + + 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 CitationBadge { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (source, number) = (self.source, self.number); + let prose = theme.prose_width(); + let open = self.on_open.clone(); + let pill = div() + .id((self.id.clone(), "pill")) + .px_1p5() + .rounded_full() + .border_1() + .border_color(colors.border) + .bg(colors.hover) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(open, |pill, open| { + pill.on_click(move |_, window, cx| { + log::info!("citation: open {number}"); + open(window, cx) + }) + }) + .child(tabular(div()).child(format!("{number}"))); + HoverCard::new(self.id, pill, move |_, cx| { + div().w(prose).child(lines(Some(number), &source, cx)) + }) + } +} + +/// An answer's sources, folded under how many there are with their sites in a row; open, each as a card, numbered as cited. A press opens one. +#[derive(IntoElement)] +pub struct SourceList { + id: ElementId, + sources: Vec, + on_open: Option, +} + +impl SourceList { + pub fn new(id: impl Into, sources: impl IntoIterator) -> Self { + Self { + id: id.into(), + sources: sources.into_iter().collect(), + on_open: None, + } + } + + /// Gets the index of the source to open. + 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 SourceList { + 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 count = self.sources.len(); + let sites: Vec = self + .sources + .iter() + .map(|source| source.site.clone()) + .take(3) + .collect(); + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .child( + div() + .id((self.id.clone(), "toggle")) + .flex() + .items_center() + .gap_2() + .px_1() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(transparent_black()) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .text_size(theme.text_size(TextSize::Sm)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + open.update(cx, |open, cx| { + *open = !*open; + log::info!("source list: open {open}"); + cx.notify(); + }) + }) + .child(div().font_weight(FontWeight::MEDIUM).child(match count { + 1 => "1 source".to_string(), + n => format!("{n} sources"), + })) + .child(div().text_color(colors.fg_subtle).child(sites.join(" · "))) + .child( + Disclosure::new((self.id.clone(), "chevron"), opened).size(IconSize::Sm), + ), + ) + .when(opened, |list| { + list.child(div().flex().flex_col().gap_2().children( + self.sources.into_iter().enumerate().map(|(ix, source)| { + let card = + SourceCard::new((self.id.clone(), format!("source-{ix}")), source) + .number(ix + 1); + match self.on_open.clone() { + Some(open) => card.on_open(move |window, cx| open(ix, window, cx)), + None => card, + } + }), + )) + }) + } +} diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 4add991..93a2499 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -1,9 +1,11 @@ mod actions; +mod cite; mod code; mod list; mod media; mod message; mod play; +mod search; mod status; mod stream; #[cfg(all(test, feature = "test-support"))] @@ -13,6 +15,7 @@ pub use actions::{ BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button, stop_button, }; +pub use cite::{CitationBadge, Source, SourceCard, SourceList}; pub use code::CodeBlock; pub use list::{DateSeparator, MessageList, ScrollToBottomButton}; pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard}; @@ -20,6 +23,7 @@ pub use message::{ ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role, }; pub use play::{AudioMessage, VideoMessage}; +pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard}; pub use status::{ ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator, }; diff --git a/src/chat/search.rs b/src/chat/search.rs new file mode 100644 index 0000000..094cfcb --- /dev/null +++ b/src/chat/search.rs @@ -0,0 +1,289 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, + Window, div, prelude::*, +}; + +use super::cite::Source; +use crate::{ + forms::Run, + motion::Spinner, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::{Ellipsis, tabular}, +}; + +/// Where a search step stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StepState { + Waiting, + Working, + Done, +} + +/// A search as it runs, step by step: what it looks for, what it reads, each step waiting, working or done. +#[derive(IntoElement)] +pub struct SearchProgress { + id: ElementId, + steps: Vec<(SharedString, StepState)>, +} + +impl SearchProgress { + pub fn new( + id: impl Into, + steps: impl IntoIterator, StepState)>, + ) -> Self { + Self { + id: id.into(), + steps: steps + .into_iter() + .map(|(label, state)| (label.into(), state)) + .collect(), + } + } +} + +impl RenderOnce for SearchProgress { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .children( + self.steps + .into_iter() + .enumerate() + .map(|(ix, (label, state))| { + let mark = match state { + StepState::Done => Icon::new(IconName::Check) + .size(IconSize::Sm) + .color(colors.success) + .into_any_element(), + StepState::Working => { + Spinner::new((self.id.clone(), format!("step-{ix}"))) + .size(IconSize::Sm) + .into_any_element() + } + StepState::Waiting => div() + .size(theme.status_dot()) + .rounded_full() + .bg(colors.fg_subtle) + .into_any_element(), + }; + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_none() + .size(theme.icon_size(IconSize::Sm)) + .flex() + .items_center() + .justify_center() + .child(mark), + ) + .child( + div() + .text_color(match state { + StepState::Waiting => colors.fg_subtle, + StepState::Working => colors.fg, + StepState::Done => colors.fg_muted, + }) + .child(label), + ) + }), + ) + } +} + +/// A search result: its title as a link, where it lives, a line from it and when it was written; a press opens it. +#[derive(IntoElement)] +pub struct WebResultCard { + id: ElementId, + source: Source, + date: Option, + on_open: Option, +} + +impl WebResultCard { + pub fn new(id: impl Into, source: Source) -> Self { + Self { + id: id.into(), + source, + date: None, + on_open: None, + } + } + + pub fn date(mut self, date: impl Into) -> Self { + self.date = Some(date.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 WebResultCard { + 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_full() + .flex() + .flex_col() + .gap_0p5() + .py_2() + .when_some(open, |card, open| { + card.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| open(window, cx)) + }) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .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( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(self.source.url.clone())), + ) + .children(self.date.map(|date| div().child(date))), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Base)) + .text_color(colors.link) + .child(self.source.title), + ) + .children(self.source.snippet.map(|snippet| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(snippet) + })) + } +} + +/// A passage found in a document for an answer: the document and where in it, the passage with the words that matched washed, and how close a match it was. +#[derive(IntoElement)] +pub struct DocumentChunkPreview { + document: SharedString, + place: SharedString, + text: SharedString, + matched: Vec>, + score: Option, +} + +impl DocumentChunkPreview { + /// `matched` are byte ranges in `text`, in order. + pub fn new( + document: impl Into, + place: impl Into, + text: impl Into, + matched: Vec>, + ) -> Self { + let text = text.into(); + assert!( + matched.iter().all(|range| range.end <= text.len() + && text.is_char_boundary(range.start) + && text.is_char_boundary(range.end)), + "a match lies outside the passage" + ); + assert!( + matched.windows(2).all(|pair| pair[0].end <= pair[1].start), + "matches come in order without overlapping" + ); + Self { + document: document.into(), + place: place.into(), + text, + matched, + score: None, + } + } + + /// How close a match, 0 to 1. + pub fn score(mut self, score: f32) -> Self { + assert!( + (0.0..=1.0).contains(&score), + "a score of {score} lies outside 0 to 1" + ); + self.score = Some(score); + self + } +} + +impl RenderOnce for DocumentChunkPreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let wash = HighlightStyle { + background_color: Some(colors.warning.opacity(0.25)), + ..HighlightStyle::default() + }; + div() + .w_full() + .flex() + .flex_col() + .gap_1p5() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child( + Icon::new(IconName::FileText) + .size(IconSize::Xs) + .color(colors.fg_subtle), + ) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(self.document), + ) + .child(div().flex_1().child(self.place)) + .children( + self.score.map(|score| { + tabular(div()).child(format!("{:.0}% match", score * 100.0)) + }), + ), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child( + StyledText::new(self.text) + .with_highlights(self.matched.into_iter().map(|range| (range, wash))), + ), + ) + } +} diff --git a/src/chat/tests.rs b/src/chat/tests.rs index 7930567..aa6fa27 100644 --- a/src/chat/tests.rs +++ b/src/chat/tests.rs @@ -8,7 +8,8 @@ use gpui::{ }; use super::{ - ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock, + CitationBadge, ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, + Source, SourceList, ThinkingBlock, }; use crate::{forms, forms::TextInput, theme::Theme}; @@ -356,3 +357,88 @@ fn the_reasoning_opens_from_the_keyboard(cx: &mut TestAppContext) { "Tab and Space open it: {closed:?} to {open:?}" ); } + +fn source(title: &str) -> Source { + Source { + site: "example.com".into(), + title: title.to_string().into(), + url: "https://example.com".into(), + snippet: None, + } +} + +/// Sources folded under their count, the list's box measured, and how often one opened. +struct Cited { + frame: Measured, + opened: usize, +} + +impl Render for Cited { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (frame, me) = (self.frame.clone(), cx.entity()); + div() + .size_full() + .child( + CitationBadge::new("cite", 1, source("Gamma")) + .on_open(move |_, cx| me.update(cx, |cited, _| cited.opened += 1)), + ) + .child( + div() + .w(px(320.0)) + .relative() + .child(SourceList::new( + "sources", + [source("Gamma"), source("Lift")], + )) + .child( + canvas( + move |bounds, _, _| *frame.borrow_mut() = Some(bounds), + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ) + } +} + +fn release(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("the key parses"), + }); + settle(cx); +} + +#[gpui::test] +fn citations_and_sources_open_from_the_keyboard(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let frame = Measured::default(); + let seen = frame.clone(); + let (cited, cx) = cx.add_window_view(move |_, _| Cited { + frame: seen, + opened: 0, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + cx.update(|window, _| window.focus_next()); + release("enter", cx); + assert_eq!( + cited.read_with(cx, |cited, _| cited.opened), + 1, + "the badge opens its source" + ); + let folded = frame.borrow().expect("the list is laid out").size.height; + cx.update(|window, _| window.focus_next()); + release("space", cx); + let open = frame.borrow().expect("the list is laid out").size.height; + assert!( + open > folded, + "Tab and Space open the sources: {folded:?} to {open:?}" + ); +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 51b16aa..2aca864 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -112,12 +112,12 @@ ## T23b AI Chat · Citations -- [ ] CitationBadge / InlineCitation -- [ ] SourceCard -- [ ] SourceList / SourcesPanel -- [ ] SearchProgress -- [ ] WebResultCard -- [ ] DocumentChunkPreview +- [x] CitationBadge / InlineCitation → `chat::CitationBadge`, set after the words it cites +- [x] SourceCard +- [x] SourceList / SourcesPanel → `chat::SourceList` +- [x] SearchProgress +- [x] WebResultCard +- [x] DocumentChunkPreview ## T23c AI Chat · Input