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
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 06:07:42 -04:00
1 parent 8889be4f38
commit c3e9f14241
8 files changed
+794 -8

No files matched your search

+1 -1
View File
@@ -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
+123
View File
@@ -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<Source> {
[
(
"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),
),
)
}
+12
View File
@@ -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()
}
+272
View File
@@ -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<SharedString>,
}
/// 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<usize>,
source: Source,
on_open: Option<Run>,
}
impl SourceCard {
pub fn new(id: impl Into<ElementId>, 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<usize>, 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<Run>,
}
impl CitationBadge {
pub fn new(id: impl Into<ElementId>, 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<Source>,
on_open: Option<Pick>,
}
impl SourceList {
pub fn new(id: impl Into<ElementId>, sources: impl IntoIterator<Item = Source>) -> 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<SharedString> = 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,
}
}),
))
})
}
}
+4
View File
@@ -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,
};
+289
View File
@@ -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<ElementId>,
steps: impl IntoIterator<Item = (impl Into<SharedString>, 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<SharedString>,
on_open: Option<Run>,
}
impl WebResultCard {
pub fn new(id: impl Into<ElementId>, source: Source) -> Self {
Self {
id: id.into(),
source,
date: None,
on_open: None,
}
}
pub fn date(mut self, date: impl Into<SharedString>) -> 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<Range<usize>>,
score: Option<f32>,
}
impl DocumentChunkPreview {
/// `matched` are byte ranges in `text`, in order.
pub fn new(
document: impl Into<SharedString>,
place: impl Into<SharedString>,
text: impl Into<SharedString>,
matched: Vec<Range<usize>>,
) -> 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))),
),
)
}
}
+87 -1
View File
@@ -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<Self>) -> 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:?}"
);
}
+6 -6
View File
@@ -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