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:
8 files changed
+794
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user