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] T21c Documents · Knowledge
|
||||||
- [x] T22 Collaboration
|
- [x] T22 Collaboration
|
||||||
- [x] T23a AI Chat · Messages
|
- [x] T23a AI Chat · Messages
|
||||||
- [ ] T23b AI Chat · Citations
|
- [x] T23b AI Chat · Citations
|
||||||
- [ ] T23c AI Chat · Input
|
- [ ] T23c AI Chat · Input
|
||||||
- [ ] T23d AI Chat · Conversations
|
- [ ] T23d AI Chat · Conversations
|
||||||
- [ ] T23e AI Chat · Welcome
|
- [ ] 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 blocks;
|
||||||
|
mod cite;
|
||||||
mod controls;
|
mod controls;
|
||||||
mod talk;
|
mod talk;
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::Shot("editing"),
|
Step::Shot("editing"),
|
||||||
Step::Key("escape"),
|
Step::Key("escape"),
|
||||||
Step::Wait(200),
|
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 {
|
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::actions(window, cx))
|
||||||
.child(controls::status(window, cx))
|
.child(controls::status(window, cx))
|
||||||
.child(controls::thinking(window, cx))
|
.child(controls::thinking(window, cx))
|
||||||
|
.child(cite::citations(window, cx))
|
||||||
|
.child(cite::search(window, cx))
|
||||||
.into_any_element()
|
.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 actions;
|
||||||
|
mod cite;
|
||||||
mod code;
|
mod code;
|
||||||
mod list;
|
mod list;
|
||||||
mod media;
|
mod media;
|
||||||
mod message;
|
mod message;
|
||||||
mod play;
|
mod play;
|
||||||
|
mod search;
|
||||||
mod status;
|
mod status;
|
||||||
mod stream;
|
mod stream;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
@@ -13,6 +15,7 @@ pub use actions::{
|
|||||||
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
|
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
|
||||||
stop_button,
|
stop_button,
|
||||||
};
|
};
|
||||||
|
pub use cite::{CitationBadge, Source, SourceCard, SourceList};
|
||||||
pub use code::CodeBlock;
|
pub use code::CodeBlock;
|
||||||
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
|
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
|
||||||
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
|
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
|
||||||
@@ -20,6 +23,7 @@ pub use message::{
|
|||||||
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
|
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
|
||||||
};
|
};
|
||||||
pub use play::{AudioMessage, VideoMessage};
|
pub use play::{AudioMessage, VideoMessage};
|
||||||
|
pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard};
|
||||||
pub use status::{
|
pub use status::{
|
||||||
ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator,
|
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::{
|
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};
|
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:?}"
|
"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
|
## T23b AI Chat · Citations
|
||||||
|
|
||||||
- [ ] CitationBadge / InlineCitation
|
- [x] CitationBadge / InlineCitation → `chat::CitationBadge`, set after the words it cites
|
||||||
- [ ] SourceCard
|
- [x] SourceCard
|
||||||
- [ ] SourceList / SourcesPanel
|
- [x] SourceList / SourcesPanel → `chat::SourceList`
|
||||||
- [ ] SearchProgress
|
- [x] SearchProgress
|
||||||
- [ ] WebResultCard
|
- [x] WebResultCard
|
||||||
- [ ] DocumentChunkPreview
|
- [x] DocumentChunkPreview
|
||||||
|
|
||||||
## T23c AI Chat · Input
|
## T23c AI Chat · Input
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user