1use crate::stream::{ProgressState, StreamedContent};
4use crate::theme::SemanticStyledExt as _;
5use crate::{
6 control::{composed_button, outlined_control},
7 handlers::SharedHandler,
8 surface::initial_badge,
9};
10use gpui::{
11 AnyElement, App, Axis, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
12 ParentElement as _, RenderOnce, Role, ScrollHandle, SharedString,
13 StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
14 prelude::FluentBuilder as _,
15};
16use gpui_base::Button;
17use gpui_component::{
18 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, hover_card::HoverCard,
19 scroll::ScrollableMask, text::TextView, v_flex,
20};
21use std::{
22 collections::{HashMap, HashSet},
23 rc::Rc,
24};
25
26const STREAMING_CURSOR: &str = "▌";
28
29#[cfg(test)]
30thread_local! {
31 static TRANSFORM_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
35
36 static RENDERED_SOURCE: std::cell::RefCell<Option<SharedString>> =
38 const { std::cell::RefCell::new(None) };
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SourceRef {
48 id: SharedString,
49 title: SharedString,
50 url: Option<SharedString>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FollowUp {
56 id: SharedString,
57 label: SharedString,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct CitationRef {
67 id: SharedString,
68 label: SharedString,
69 title: SharedString,
70 destination: SharedString,
71}
72
73impl CitationRef {
74 pub fn new(
76 id: impl Into<SharedString>,
77 label: impl Into<SharedString>,
78 title: impl Into<SharedString>,
79 destination: impl Into<SharedString>,
80 ) -> Self {
81 Self {
82 id: id.into(),
83 label: label.into(),
84 title: title.into(),
85 destination: destination.into(),
86 }
87 }
88
89 pub fn id(&self) -> &SharedString {
91 &self.id
92 }
93
94 pub fn label(&self) -> &SharedString {
96 &self.label
97 }
98
99 pub fn title(&self) -> &SharedString {
101 &self.title
102 }
103
104 pub fn destination(&self) -> &SharedString {
106 &self.destination
107 }
108
109 fn internal_url(&self) -> String {
110 format!("gpui-ai-citation://{}", percent_encode_id(&self.id))
111 }
112}
113
114impl FollowUp {
115 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
117 Self {
118 id: id.into(),
119 label: label.into(),
120 }
121 }
122
123 fn selected_event(&self) -> StreamingTextEvent {
124 StreamingTextEvent::FollowUpSelected {
125 id: self.id.clone(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum StreamingTextEvent {
133 FollowUpSelected {
135 id: SharedString,
137 },
138 CitationActivated {
140 id: SharedString,
142 destination: SharedString,
144 },
145 SourceActivated {
147 id: SharedString,
149 url: SharedString,
151 },
152}
153
154fn percent_encode_id(id: &str) -> String {
155 let mut encoded = String::with_capacity(id.len());
156 for byte in id.bytes() {
157 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
158 encoded.push(char::from(byte));
159 } else {
160 use std::fmt::Write as _;
161 let _ = write!(encoded, "%{byte:02X}");
162 }
163 }
164 encoded
165}
166
167fn escape_markdown_label(label: &str) -> String {
168 let mut escaped = String::with_capacity(label.len());
169 for character in label.chars() {
170 if matches!(character, '\\' | '[' | ']') {
171 escaped.push('\\');
172 }
173 escaped.push(character);
174 }
175 escaped
176}
177
178fn escape_markdown_title(title: &str) -> String {
179 let mut escaped = String::with_capacity(title.len());
180 for character in title.chars() {
181 match character {
182 '\\' | '"' => {
183 escaped.push('\\');
184 escaped.push(character);
185 }
186 '\r' | '\n' => escaped.push(' '),
187 _ => escaped.push(character),
188 }
189 }
190 escaped
191}
192
193#[derive(Clone, Copy)]
194struct FenceCandidate<'a> {
195 marker: u8,
196 length: usize,
197 trailing: &'a str,
198}
199
200#[derive(Clone, Copy)]
201struct OpenFence {
202 marker: u8,
203 length: usize,
204 quote_depth: usize,
205}
206
207fn block_quote_content(mut line: &str) -> (usize, &str) {
208 let mut depth = 0;
209 loop {
210 let bytes = line.as_bytes();
211 let indentation = bytes.iter().take_while(|byte| **byte == b' ').count();
212 if indentation > 3 || bytes.get(indentation) != Some(&b'>') {
213 return (depth, line);
214 }
215
216 depth += 1;
217 line = &line[indentation + 1..];
218 if line.starts_with([' ', '\t']) {
219 line = &line[1..];
220 }
221 }
222}
223
224fn fence_candidate(line: &str) -> Option<FenceCandidate<'_>> {
225 let bytes = line.as_bytes();
226 let indentation = bytes.iter().take_while(|byte| **byte == b' ').count();
227 if indentation > 3 {
228 return None;
229 }
230 let marker = *bytes.get(indentation)?;
231 if !matches!(marker, b'`' | b'~') {
232 return None;
233 }
234 let length = bytes[indentation..]
235 .iter()
236 .take_while(|byte| **byte == marker)
237 .count();
238 (length >= 3).then_some(FenceCandidate {
239 marker,
240 length,
241 trailing: &line[indentation + length..],
242 })
243}
244
245fn opens_fence(candidate: FenceCandidate<'_>) -> bool {
246 candidate.marker != b'`' || !candidate.trailing.contains('`')
247}
248
249fn closes_fence(candidate: FenceCandidate<'_>, open: OpenFence) -> bool {
250 candidate.marker == open.marker
251 && candidate.length >= open.length
252 && candidate
253 .trailing
254 .trim_end_matches(['\r', '\n'])
255 .bytes()
256 .all(|byte| matches!(byte, b' ' | b'\t'))
257}
258
259#[cfg(test)]
260fn transform_citation_markers(
261 source: &str,
262 citations: &[CitationRef],
263 interactive: bool,
264) -> String {
265 transform_citations(source, citations, interactive).markdown
266}
267
268struct CitationTransform {
269 markdown: String,
270 referenced: Vec<CitationRef>,
271}
272
273fn citation_index(citations: &[CitationRef]) -> HashMap<&str, &CitationRef> {
276 let mut index = HashMap::with_capacity(citations.len());
277 for citation in citations {
278 index.entry(citation.id.as_str()).or_insert(citation);
279 }
280 index
281}
282
283fn transform_citations(
284 source: &str,
285 citations: &[CitationRef],
286 interactive: bool,
287) -> CitationTransform {
288 #[cfg(test)]
289 TRANSFORM_CALLS.with(|calls| calls.set(calls.get() + 1));
290
291 let mut by_id: Option<HashMap<&str, &CitationRef>> = None;
297
298 let mut transformed = String::with_capacity(source.len());
299 let mut referenced = Vec::new();
300 let mut referenced_ids: HashSet<&str> = HashSet::new();
301 let mut fence: Option<OpenFence> = None;
302 let mut inline_ticks: Option<usize> = None;
303
304 for line in source.split_inclusive('\n') {
305 let (quote_depth, quote_content) = block_quote_content(line);
306 let candidate = fence_candidate(quote_content);
307
308 if let Some(open) = fence {
309 if quote_depth >= open.quote_depth {
310 transformed.push_str(line);
311 if quote_depth == open.quote_depth
312 && candidate.is_some_and(|candidate| closes_fence(candidate, open))
313 {
314 fence = None;
315 }
316 continue;
317 }
318 fence = None;
319 }
320
321 if inline_ticks.is_none()
322 && let Some(candidate) = candidate
323 && opens_fence(candidate)
324 {
325 fence = Some(OpenFence {
326 marker: candidate.marker,
327 length: candidate.length,
328 quote_depth,
329 });
330 transformed.push_str(line);
331 continue;
332 }
333
334 let mut offset = 0;
335 while offset < line.len() {
336 let remainder = &line[offset..];
337 if remainder.starts_with('`') {
338 let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
339 transformed.push_str(&remainder[..run]);
340 match inline_ticks {
341 Some(open) if open == run => inline_ticks = None,
342 None => inline_ticks = Some(run),
343 _ => {}
344 }
345 offset += run;
346 continue;
347 }
348
349 if inline_ticks.is_none()
350 && let Some(marker) = remainder.strip_prefix("[[cite:")
351 && let Some(end) = marker.find("]]")
352 {
353 let id = &marker[..end];
354 let index = by_id.get_or_insert_with(|| citation_index(citations));
355 if let Some(citation) = index.get(id).copied() {
356 if referenced_ids.insert(citation.id.as_str()) {
357 referenced.push(citation.clone());
358 }
359 let label = escape_markdown_label(&citation.label);
360 if interactive {
361 transformed.push('[');
362 transformed.push_str(&label);
363 transformed.push_str("](");
364 transformed.push_str(&citation.internal_url());
365 transformed.push_str(" \"");
366 transformed.push_str(&escape_markdown_title(&citation.title));
367 transformed.push_str("\")");
368 } else {
369 transformed.push_str("\\[");
370 transformed.push_str(&label);
371 transformed.push_str("\\]");
372 }
373 offset += "[[cite:".len() + end + "]]".len();
374 continue;
375 }
376 }
377
378 if let Some(character) = remainder.chars().next() {
379 transformed.push(character);
380 offset += character.len_utf8();
381 } else {
382 break;
383 }
384 }
385 }
386
387 CitationTransform {
388 markdown: transformed,
389 referenced,
390 }
391}
392
393fn decorated_source(markdown: &SharedString, streaming: bool) -> SharedString {
394 if !streaming {
395 return markdown.clone();
396 }
397 let mut source = String::with_capacity(markdown.len() + STREAMING_CURSOR.len());
398 source.push_str(markdown);
399 source.push_str(STREAMING_CURSOR);
400 SharedString::from(source)
401}
402
403#[derive(PartialEq, Eq)]
412struct CitationKey {
413 revision: u64,
414 interactive: bool,
415 streaming: bool,
416 citations: Vec<CitationRef>,
417 text: SharedString,
418}
419
420impl CitationKey {
421 fn transforms_alike(&self, other: &Self) -> bool {
425 self.interactive == other.interactive
426 && self.text == other.text
427 && self.citations == other.citations
428 }
429}
430
431struct CitationCache {
437 key: Option<CitationKey>,
438 markdown: SharedString,
439 source: SharedString,
440 referenced: Vec<CitationRef>,
441}
442
443impl CitationCache {
444 fn new() -> Self {
445 Self {
446 key: None,
447 markdown: SharedString::default(),
448 source: SharedString::default(),
449 referenced: Vec::new(),
450 }
451 }
452
453 fn refresh(&mut self, key: CitationKey) {
454 if self.key.as_ref().is_some_and(|cached| *cached == key) {
455 return;
456 }
457 let transform_stale = self
458 .key
459 .as_ref()
460 .is_none_or(|cached| !cached.transforms_alike(&key));
461 if transform_stale {
462 let transformed = transform_citations(&key.text, &key.citations, key.interactive);
463 self.markdown = SharedString::from(transformed.markdown);
464 self.referenced = transformed.referenced;
465 }
466 let cursor_stale = self
467 .key
468 .as_ref()
469 .is_none_or(|cached| cached.streaming != key.streaming);
470 if transform_stale || cursor_stale {
471 self.source = decorated_source(&self.markdown, key.streaming);
472 }
473 self.key = Some(key);
474 }
475}
476
477fn citation_event_for_url(url: &str, citations: &[CitationRef]) -> Option<StreamingTextEvent> {
478 citations
479 .iter()
480 .find(|citation| citation.internal_url() == url)
481 .map(|citation| StreamingTextEvent::CitationActivated {
482 id: citation.id.clone(),
483 destination: citation.destination.clone(),
484 })
485}
486
487fn citation_companion_link(
488 root_id: ElementId,
489 citation: CitationRef,
490 handler: SharedHandler<StreamingTextEvent>,
491 cx: &mut App,
492) -> gpui_base::Link {
493 let tokens = cx.theme().semantic_tokens();
494 let event = StreamingTextEvent::CitationActivated {
495 id: citation.id.clone(),
496 destination: citation.destination.clone(),
497 };
498 let local_id = SharedString::from(format!("citation-{}", citation.id));
499 let debug_id = citation.id.clone();
500 let visible_label = SharedString::from(format!("Citation: {}", citation.label));
501
502 gpui_base::Link::new((root_id, local_id))
503 .debug_selector(move || format!("streaming-citation-{debug_id}"))
504 .accessibility_label(citation.title)
505 .flex()
506 .flex_none()
507 .items_center()
508 .px(tokens.spacing.sm)
509 .py(tokens.spacing.xxs)
510 .border_1()
511 .border_color(tokens.colors.border)
512 .rounded(tokens.radius.sm)
513 .bg(tokens.colors.surface)
514 .text_token(tokens.typography.xs)
515 .text_color(tokens.colors.surface_foreground)
516 .hover(|style| style.bg(cx.theme().button_hover))
517 .active(|style| style.bg(cx.theme().button_active))
518 .focus_visible(|style| style.border_color(tokens.colors.ring))
519 .on_activate(move |_, window, cx| handler(&event, window, cx))
520 .child(div().child(visible_label))
521}
522
523fn follow_up_button(
524 follow_up: FollowUp,
525 handler: Option<SharedHandler<StreamingTextEvent>>,
526 window: &mut Window,
527 cx: &mut App,
528) -> Button {
529 let debug_id = follow_up.id.clone();
530 let event = follow_up.selected_event();
531 outlined_control(follow_up.id.clone(), follow_up.label, window, cx)
532 .debug_selector(move || format!("streaming-follow-up-{debug_id}"))
533 .accessibility_id(format!("follow-up-{}", follow_up.id))
534 .when_some(handler, |this, handler| {
535 this.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
536 })
537}
538
539fn citation_preview(title: SharedString, destination: SharedString, cx: &mut App) -> AnyElement {
540 let tokens = cx.theme().semantic_tokens();
541 v_flex()
542 .gap(tokens.spacing.xxs)
543 .max_w(tokens.spacing.xxl * 9.0)
544 .child(crate::surface::subtitle(title, cx))
545 .child(
546 div()
547 .text_token(tokens.typography.xs)
548 .font_family(cx.theme().mono_font_family.clone())
549 .text_color(cx.theme().muted_foreground)
550 .truncate()
551 .child(destination),
552 )
553 .into_any_element()
554}
555
556fn source_chip(
559 root_id: ElementId,
560 index: usize,
561 source: SourceRef,
562 handler: Option<SharedHandler<StreamingTextEvent>>,
563 cx: &mut App,
564) -> AnyElement {
565 let tokens = cx.theme().semantic_tokens();
566 let accessibility_label = source.accessibility_label();
567 let badge = initial_badge(source.initial(), cx);
568 let content = h_flex()
569 .items_center()
570 .gap(tokens.spacing.xs)
571 .text_token(tokens.typography.xs)
572 .text_color(cx.theme().foreground)
573 .child(badge)
574 .child(source.title.clone())
575 .when(source.url.is_some(), |this| {
576 this.child(
577 Icon::new(IconName::ExternalLink)
578 .xsmall()
579 .text_color(cx.theme().muted_foreground),
580 )
581 });
582 match (source.url.clone(), handler) {
583 (Some(url), Some(handler)) => {
584 let event = StreamingTextEvent::SourceActivated {
585 id: source.id.clone(),
586 url,
587 };
588 let debug_id = source.id.clone();
589 composed_button((root_id, format!("source-{index}")), accessibility_label)
590 .debug_selector(move || format!("streaming-source-{debug_id}"))
591 .px(tokens.spacing.sm)
592 .py(tokens.spacing.xxs)
593 .bg(cx.theme().secondary)
594 .border_1()
595 .border_color(cx.theme().border)
596 .rounded(tokens.radius.full)
597 .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
598 .active(|style| style.bg(cx.theme().accent))
599 .focus_visible(|style| style.border_color(cx.theme().ring))
600 .child(content)
601 .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
602 .into_any_element()
603 }
604 _ => h_flex()
605 .id((root_id, index.to_string()))
606 .role(Role::ListItem)
607 .aria_label(accessibility_label)
608 .px(tokens.spacing.sm)
609 .py(tokens.spacing.xxs)
610 .bg(cx.theme().secondary)
611 .border_1()
612 .border_color(cx.theme().border)
613 .rounded(tokens.radius.full)
614 .child(content)
615 .into_any_element(),
616 }
617}
618
619struct CitationDispatcher {
620 citations: Vec<CitationRef>,
621 on_event: SharedHandler<StreamingTextEvent>,
622}
623
624impl CitationDispatcher {
625 fn activate(&self, url: &str, event: &ClickEvent, window: &mut Window, cx: &mut App) -> bool {
626 if event.is_right_click() {
627 return true;
628 }
629 let Some(event) = citation_event_for_url(url, &self.citations) else {
630 return false;
631 };
632 (self.on_event)(&event, window, cx);
633 true
634 }
635}
636
637impl SourceRef {
638 pub fn new(title: impl Into<SharedString>) -> Self {
641 let title = title.into();
642 Self {
643 id: title.clone(),
644 title,
645 url: None,
646 }
647 }
648
649 pub fn with_id(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
651 Self {
652 id: id.into(),
653 title: title.into(),
654 url: None,
655 }
656 }
657
658 pub fn url(mut self, url: impl Into<SharedString>) -> Self {
661 self.url = Some(url.into());
662 self
663 }
664
665 pub fn id(&self) -> &SharedString {
667 &self.id
668 }
669
670 pub fn title(&self) -> &SharedString {
672 &self.title
673 }
674
675 pub fn url_text(&self) -> Option<&SharedString> {
677 self.url.as_ref()
678 }
679
680 pub fn domain(&self) -> Option<String> {
682 let url = self.url.as_ref()?;
683 let rest = url
684 .split_once("://")
685 .map(|(_, rest)| rest)
686 .unwrap_or(url.as_ref());
687 let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
688 let host = host.strip_prefix("www.").unwrap_or(host);
689 (!host.is_empty()).then(|| host.to_owned())
690 }
691
692 pub fn initial(&self) -> String {
694 crate::surface::initial_of(&self.domain().unwrap_or_else(|| self.title.to_string()))
695 }
696
697 fn accessibility_label(&self) -> SharedString {
698 match self.domain() {
699 Some(domain) => format!("{}, {domain}", self.title).into(),
700 None => self.title.clone(),
701 }
702 }
703}
704
705#[derive(IntoElement)]
732pub struct StreamingText {
733 id: ElementId,
734 style: StyleRefinement,
735 text: SharedString,
736 state: ProgressState,
737 revision: u64,
740 sources: Vec<SourceRef>,
741 citations: Vec<CitationRef>,
742 follow_ups: Vec<FollowUp>,
743 on_event: Option<SharedHandler<StreamingTextEvent>>,
744}
745
746impl StreamingText {
747 pub fn new(id: impl Into<ElementId>, content: &StreamedContent) -> Self {
749 Self {
750 id: id.into(),
751 style: StyleRefinement::default(),
752 text: SharedString::from(content.text().to_string()),
753 state: content.state().clone(),
754 revision: content.revision(),
755 sources: Vec::new(),
756 citations: Vec::new(),
757 follow_ups: Vec::new(),
758 on_event: None,
759 }
760 }
761
762 pub fn sources(mut self, sources: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
764 self.sources = sources.into_iter().map(SourceRef::new).collect();
765 self
766 }
767
768 pub fn source_refs(mut self, sources: impl IntoIterator<Item = SourceRef>) -> Self {
771 self.sources = sources.into_iter().collect();
772 self
773 }
774
775 pub fn citations(mut self, citations: impl IntoIterator<Item = CitationRef>) -> Self {
783 self.citations = citations.into_iter().collect();
784 self
785 }
786
787 pub fn follow_ups(mut self, follow_ups: impl IntoIterator<Item = FollowUp>) -> Self {
789 self.follow_ups = follow_ups.into_iter().collect();
790 self
791 }
792
793 pub fn on_event(
795 mut self,
796 handler: impl Fn(&StreamingTextEvent, &mut Window, &mut App) + 'static,
797 ) -> Self {
798 self.on_event = Some(Rc::new(handler));
799 self
800 }
801}
802
803impl Styled for StreamingText {
804 fn style(&mut self) -> &mut StyleRefinement {
805 &mut self.style
806 }
807}
808
809impl RenderOnce for StreamingText {
810 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
811 let tokens = cx.theme().semantic_tokens();
812 let streaming = self.state == ProgressState::Running;
813 let settled = self.state == ProgressState::Complete;
814 let interactive_citations = self.on_event.is_some();
815 let root_id = self.id.clone();
816 let sources_settled = crate::motion::acknowledged_state(
817 ElementId::from((root_id.clone(), "sources-settled")),
818 (settled && !self.sources.is_empty()) as u64,
819 window,
820 cx,
821 );
822 let cache = window.use_keyed_state((root_id.clone(), "citation-cache"), cx, |_, _| {
823 CitationCache::new()
824 });
825 let (source, referenced_citations) = cache.update(cx, |cache, _| {
826 cache.refresh(CitationKey {
827 revision: self.revision,
828 interactive: interactive_citations,
829 streaming,
830 citations: self.citations.clone(),
831 text: self.text.clone(),
832 });
833 (cache.source.clone(), cache.referenced.clone())
834 });
835 #[cfg(test)]
836 RENDERED_SOURCE.with(|rendered| *rendered.borrow_mut() = Some(source.clone()));
837 let on_event = self.on_event.clone();
838 let failure = match &self.state {
839 ProgressState::Failed(reason) => Some(reason.clone()),
840 _ => None,
841 };
842 let dispatcher = on_event.as_ref().map(|handler| {
843 let initial_citations = referenced_citations.clone();
844 let initial_handler = handler.clone();
845 let dispatcher =
846 window.use_keyed_state((root_id.clone(), "citation-dispatch"), cx, move |_, _| {
847 CitationDispatcher {
848 citations: initial_citations,
849 on_event: initial_handler,
850 }
851 });
852 dispatcher.update(cx, |dispatcher, _| {
853 dispatcher.citations = referenced_citations.clone();
854 dispatcher.on_event = handler.clone();
855 });
856 dispatcher
857 });
858 let mut answer = TextView::markdown((root_id.clone(), "answer"), source).selectable(true);
859 if let Some(dispatcher) = dispatcher {
860 let dispatcher = dispatcher.downgrade();
861 answer = answer.on_link_click(move |url, event, window, cx| {
862 let handled = dispatcher
863 .update(cx, |dispatcher, cx| {
864 dispatcher.activate(url, event, window, cx)
865 })
866 .is_ok_and(|handled| handled);
867 if !handled && !event.is_right_click() {
868 cx.open_url(url);
869 }
870 });
871 }
872 let citation_scroll_handle = window
873 .use_keyed_state((root_id.clone(), "citation-scroll-handle"), cx, |_, _| {
874 ScrollHandle::new()
875 })
876 .read(cx)
877 .clone();
878
879 v_flex()
880 .id(self.id)
881 .w_full()
882 .min_w_0()
883 .role(Role::Article)
884 .aria_label("Answer")
885 .when_some(failure.clone(), |this, reason| {
886 this.aria_description(reason)
887 })
888 .gap(tokens.spacing.md)
889 .child(
890 div()
891 .debug_selector(|| "streaming-text-body".to_owned())
892 .text_token(tokens.typography.sm)
893 .text_color(cx.theme().foreground)
894 .child(answer),
895 )
896 .when_some(failure, |this, reason| {
897 this.child(
901 h_flex()
902 .items_start()
903 .gap(tokens.spacing.sm)
904 .text_token(tokens.typography.xs)
905 .text_color(cx.theme().danger)
906 .child(crate::surface::leading_glyph_slot(
907 crate::sizing::slot_sm(cx),
908 Icon::new(IconName::CircleX).xsmall(),
909 ))
910 .child(div().min_w_0().child(reason)),
911 )
912 })
913 .when_some(on_event.clone(), |this, handler| {
914 this.when(!referenced_citations.is_empty(), |this| {
915 this.child(
916 v_flex()
917 .id((root_id.clone(), "inline-citations"))
918 .role(Role::Group)
919 .aria_label("Inline citation links")
920 .tab_group()
921 .gap(tokens.spacing.xxs)
922 .child(
923 div()
924 .relative()
925 .w_full()
926 .child(
927 h_flex()
928 .id((root_id.clone(), "inline-citation-scroll"))
929 .debug_selector(|| {
930 "streaming-citation-scroll".to_owned()
931 })
932 .w_full()
933 .max_w_full()
934 .min_w_0()
935 .overflow_x_scroll()
936 .track_scroll(&citation_scroll_handle)
937 .gap(tokens.spacing.xs)
938 .children(referenced_citations.into_iter().map(
939 |citation| {
940 let preview_title = citation.title.clone();
941 let preview_destination =
942 citation.destination.clone();
943 let card_id = ElementId::from((
944 root_id.clone(),
945 format!("citation-card-{}", citation.id),
946 ));
947 HoverCard::new(card_id)
948 .trigger(citation_companion_link(
949 root_id.clone(),
950 citation,
951 handler.clone(),
952 cx,
953 ))
954 .content(move |_, _, cx| {
955 citation_preview(
956 preview_title.clone(),
957 preview_destination.clone(),
958 cx,
959 )
960 })
961 },
962 )),
963 )
964 .child(
965 ScrollableMask::new(
966 Axis::Horizontal,
967 &citation_scroll_handle,
968 )
969 .id((root_id.clone(), "inline-citation-scroll-mask")),
970 ),
971 ),
972 )
973 })
974 })
975 .when(settled && !self.sources.is_empty(), |this| {
976 let settled_in = sources_settled;
982 this.child(
983 h_flex()
984 .id((root_id.clone(), "sources"))
985 .opacity(settled_in)
986 .top(tokens.spacing.xxs * (1.0 - settled_in) * crate::motion::travel(cx))
987 .role(Role::List)
988 .aria_label("Sources")
989 .flex_wrap()
990 .gap(tokens.spacing.xs)
991 .children(self.sources.into_iter().enumerate().map(|(ix, source)| {
992 source_chip(root_id.clone(), ix, source, on_event.clone(), cx)
993 })),
994 )
995 })
996 .when(settled && !self.follow_ups.is_empty(), |this| {
997 let handler = on_event.clone();
998 this.child(
999 h_flex()
1000 .id((root_id, "follow-ups"))
1001 .role(Role::Group)
1002 .aria_label("Follow-up suggestions")
1003 .flex_wrap()
1004 .gap(tokens.spacing.xs)
1005 .children(self.follow_ups.into_iter().map(|follow_up| {
1006 follow_up_button(follow_up, handler.clone(), window, cx)
1007 })),
1008 )
1009 })
1010 .refine_style(&self.style)
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use crate::stream::Progressive;
1018 use gpui::{Element as _, Entity, TestAppContext, VisualTestContext, accesskit, canvas};
1019 use std::{
1020 sync::{Arc, Mutex},
1021 time::Instant,
1022 };
1023
1024 fn transform_citations_by_linear_scan(
1027 source: &str,
1028 citations: &[CitationRef],
1029 interactive: bool,
1030 ) -> CitationTransform {
1031 let mut transformed = String::with_capacity(source.len());
1032 let mut referenced = Vec::new();
1033 let mut fence: Option<OpenFence> = None;
1034 let mut inline_ticks: Option<usize> = None;
1035
1036 for line in source.split_inclusive('\n') {
1037 let (quote_depth, quote_content) = block_quote_content(line);
1038 let candidate = fence_candidate(quote_content);
1039
1040 if let Some(open) = fence {
1041 if quote_depth >= open.quote_depth {
1042 transformed.push_str(line);
1043 if quote_depth == open.quote_depth
1044 && candidate.is_some_and(|candidate| closes_fence(candidate, open))
1045 {
1046 fence = None;
1047 }
1048 continue;
1049 }
1050 fence = None;
1051 }
1052
1053 if inline_ticks.is_none()
1054 && let Some(candidate) = candidate
1055 && opens_fence(candidate)
1056 {
1057 fence = Some(OpenFence {
1058 marker: candidate.marker,
1059 length: candidate.length,
1060 quote_depth,
1061 });
1062 transformed.push_str(line);
1063 continue;
1064 }
1065
1066 let mut offset = 0;
1067 while offset < line.len() {
1068 let remainder = &line[offset..];
1069 if remainder.starts_with('`') {
1070 let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
1071 transformed.push_str(&remainder[..run]);
1072 match inline_ticks {
1073 Some(open) if open == run => inline_ticks = None,
1074 None => inline_ticks = Some(run),
1075 _ => {}
1076 }
1077 offset += run;
1078 continue;
1079 }
1080
1081 if inline_ticks.is_none()
1082 && let Some(marker) = remainder.strip_prefix("[[cite:")
1083 && let Some(end) = marker.find("]]")
1084 {
1085 let id = &marker[..end];
1086 if let Some(citation) = citations.iter().find(|citation| citation.id == id) {
1087 if !referenced
1088 .iter()
1089 .any(|referenced: &CitationRef| referenced.id == citation.id)
1090 {
1091 referenced.push(citation.clone());
1092 }
1093 let label = escape_markdown_label(&citation.label);
1094 if interactive {
1095 transformed.push('[');
1096 transformed.push_str(&label);
1097 transformed.push_str("](");
1098 transformed.push_str(&citation.internal_url());
1099 transformed.push_str(" \"");
1100 transformed.push_str(&escape_markdown_title(&citation.title));
1101 transformed.push_str("\")");
1102 } else {
1103 transformed.push_str("\\[");
1104 transformed.push_str(&label);
1105 transformed.push_str("\\]");
1106 }
1107 offset += "[[cite:".len() + end + "]]".len();
1108 continue;
1109 }
1110 }
1111
1112 if let Some(character) = remainder.chars().next() {
1113 transformed.push(character);
1114 offset += character.len_utf8();
1115 } else {
1116 break;
1117 }
1118 }
1119 }
1120
1121 CitationTransform {
1122 markdown: transformed,
1123 referenced,
1124 }
1125 }
1126
1127 fn citations() -> [CitationRef; 2] {
1128 [
1129 CitationRef::new("pricing", "Pricing", "Open pricing source", "app://pricing"),
1130 CitationRef::new(
1131 "supplier notes",
1132 "Supplier [notes]",
1133 "Open \"supplier\" notes",
1134 "app://suppliers?section=notes",
1135 ),
1136 ]
1137 }
1138
1139 #[test]
1140 fn maps_all_shared_lifecycle_states() {
1141 let cases = [
1142 Progressive::pending("answer".to_owned()),
1143 Progressive::running("answer".to_owned()),
1144 Progressive::complete("answer".to_owned()),
1145 Progressive::failed("answer".to_owned(), "offline"),
1146 ];
1147 for content in cases {
1148 assert_eq!(
1149 StreamingText::new("answer", &content).state,
1150 *content.state()
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn sources_derive_domains_and_initials() {
1157 let web = SourceRef::new("Wholesale pricing").url("https://www.alpenrose.com/pricing?q=1");
1158 assert_eq!(web.domain().as_deref(), Some("alpenrose.com"));
1159 assert_eq!(web.initial(), "A");
1160 assert_eq!(
1161 web.accessibility_label(),
1162 "Wholesale pricing, alpenrose.com"
1163 );
1164 let file = SourceRef::new("pricing.md");
1165 assert_eq!(file.domain(), None);
1166 assert_eq!(file.initial(), "P");
1167 assert_eq!(file.id(), "pricing.md");
1168 let custom = SourceRef::with_id("src-1", "(notes)");
1169 assert_eq!(custom.initial(), "N");
1170 assert_eq!(SourceRef::new("…").initial(), "•");
1171 }
1172
1173 #[test]
1174 fn duplicate_follow_up_labels_emit_distinct_stable_ids() {
1175 let first = FollowUp::new("first", "Try again");
1176 let second = FollowUp::new("second", "Try again");
1177 assert_eq!(
1178 first.selected_event(),
1179 StreamingTextEvent::FollowUpSelected { id: "first".into() }
1180 );
1181 assert_eq!(
1182 second.selected_event(),
1183 StreamingTextEvent::FollowUpSelected {
1184 id: "second".into()
1185 }
1186 );
1187 }
1188
1189 #[test]
1190 fn complete_known_markers_become_internal_markdown_links() {
1191 assert_eq!(
1192 transform_citation_markers("See [[cite:pricing]].", &citations(), true),
1193 "See [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1194 );
1195 assert_eq!(
1196 transform_citation_markers("See [[cite:supplier notes]].", &citations(), true),
1197 "See [Supplier \\[notes\\]](gpui-ai-citation://supplier%20notes \"Open \\\"supplier\\\" notes\")."
1198 );
1199 }
1200
1201 #[test]
1202 fn unknown_and_incomplete_markers_remain_readable_literals() {
1203 assert_eq!(
1204 transform_citation_markers("Unknown [[cite:missing]].", &citations(), true),
1205 "Unknown [[cite:missing]]."
1206 );
1207 assert_eq!(
1208 transform_citation_markers("Partial [[cite:pricing", &citations(), true),
1209 "Partial [[cite:pricing"
1210 );
1211 }
1212
1213 #[test]
1214 fn noninteractive_citations_render_visible_non_link_labels() {
1215 assert_eq!(
1216 transform_citation_markers("See [[cite:pricing]].", &citations(), false),
1217 "See \\[Pricing\\]."
1218 );
1219 }
1220
1221 #[test]
1222 fn markers_inside_inline_and_fenced_code_are_not_rewritten() {
1223 let source = concat!(
1224 "Outside [[cite:pricing]] and `inline [[cite:pricing]]`.\n\n",
1225 "```text\n[[cite:pricing]]\n```\n",
1226 "~~~\n[[cite:pricing]]\n~~~\n"
1227 );
1228 let expected = concat!(
1229 "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\") and `inline [[cite:pricing]]`.\n\n",
1230 "```text\n[[cite:pricing]]\n```\n",
1231 "~~~\n[[cite:pricing]]\n~~~\n"
1232 );
1233 assert_eq!(
1234 transform_citation_markers(source, &citations(), true),
1235 expected
1236 );
1237 }
1238
1239 #[test]
1240 fn trailing_text_cannot_close_a_fenced_code_block() {
1241 let source = concat!(
1242 "```rust\n",
1243 "[[cite:pricing]]\n",
1244 "```not-a-close\n",
1245 "[[cite:pricing]]\n",
1246 "```\n",
1247 "Outside [[cite:pricing]]."
1248 );
1249 let expected = concat!(
1250 "```rust\n",
1251 "[[cite:pricing]]\n",
1252 "```not-a-close\n",
1253 "[[cite:pricing]]\n",
1254 "```\n",
1255 "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1256 );
1257
1258 assert_eq!(
1259 transform_citation_markers(source, &citations(), true),
1260 expected
1261 );
1262 }
1263
1264 #[test]
1265 fn block_quote_fences_protect_markers_and_accept_longer_closers() {
1266 let source = concat!(
1267 "> ```rust\n",
1268 "> [[cite:pricing]]\n",
1269 "> ````\n",
1270 "Outside [[cite:pricing]]."
1271 );
1272 let expected = concat!(
1273 "> ```rust\n",
1274 "> [[cite:pricing]]\n",
1275 "> ````\n",
1276 "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1277 );
1278
1279 assert_eq!(
1280 transform_citation_markers(source, &citations(), true),
1281 expected
1282 );
1283 }
1284
1285 #[test]
1286 fn duplicate_labels_route_by_stable_id_and_preserve_destination() {
1287 let refs = [
1288 CitationRef::new("first", "Report", "Open first", "app://first"),
1289 CitationRef::new("second", "Report", "Open second", "app://second"),
1290 ];
1291
1292 assert_eq!(
1293 citation_event_for_url("gpui-ai-citation://second", &refs),
1294 Some(StreamingTextEvent::CitationActivated {
1295 id: "second".into(),
1296 destination: "app://second".into(),
1297 })
1298 );
1299 }
1300
1301 #[test]
1302 fn only_complete_referenced_citations_receive_companion_controls() {
1303 let transformed = transform_citations(
1304 "[[cite:pricing]] again [[cite:pricing]] then [[cite:supplier notes",
1305 &citations(),
1306 true,
1307 );
1308
1309 assert_eq!(transformed.referenced.len(), 1);
1310 assert_eq!(transformed.referenced[0].id(), "pricing");
1311 }
1312
1313 fn numbered_citations(count: usize) -> Vec<CitationRef> {
1314 (0..count)
1315 .map(|index| {
1316 CitationRef::new(
1317 format!("source-{index}"),
1318 format!("Source [{index}]"),
1319 format!("Open \"source {index}\""),
1320 format!("app://sources/{index}?section=notes"),
1321 )
1322 })
1323 .collect()
1324 }
1325
1326 fn answer_of(bytes: usize, citations: &[CitationRef]) -> String {
1329 const SENTENCE: &str = "Wholesale prices moved again this quarter, and the report explains the shift in detail. ";
1330 let mut source = String::with_capacity(bytes + SENTENCE.len() + 64);
1331 let mut sentence = 0usize;
1332 while source.len() < bytes {
1333 source.push_str(SENTENCE);
1334 if let Some(citation) = citations.get(sentence % citations.len().max(1)) {
1335 source.push_str("[[cite:");
1336 source.push_str(citation.id());
1337 source.push_str("]] ");
1338 }
1339 sentence += 1;
1340 if sentence.is_multiple_of(4) {
1341 source.push_str("\n\n");
1342 }
1343 }
1344 source
1345 }
1346
1347 #[test]
1348 fn the_indexed_transform_matches_the_linear_scan_byte_for_byte() {
1349 let dense = numbered_citations(100);
1350 let duplicate_ids = [
1351 CitationRef::new("pricing", "First", "Open first", "app://first"),
1352 CitationRef::new("pricing", "Second", "Open second", "app://second"),
1353 ];
1354 let edge_cases = concat!(
1355 "Outside [[cite:pricing]] and `inline [[cite:pricing]]`.\n\n",
1356 "```text\n[[cite:pricing]]\n```\n",
1357 "~~~\n[[cite:supplier notes]]\n~~~\n",
1358 "> ```rust\n> [[cite:pricing]]\n> ````\n",
1359 "Unknown [[cite:missing]], partial [[cite:pricing, repeat [[cite:pricing]].\n",
1360 "Escaped [[cite:supplier notes]] twice [[cite:supplier notes]].\n"
1361 );
1362 let cases: [(&str, &[CitationRef]); 5] = [
1363 (edge_cases, &citations()),
1364 (edge_cases, &duplicate_ids),
1365 (&answer_of(4 * 1024, &dense), &dense),
1366 (&answer_of(4 * 1024, &citations()), &citations()),
1367 (&answer_of(1024, &[]), &[]),
1368 ];
1369
1370 for (source, refs) in cases {
1371 for interactive in [true, false] {
1372 let indexed = transform_citations(source, refs, interactive);
1373 let scanned = transform_citations_by_linear_scan(source, refs, interactive);
1374 assert_eq!(indexed.markdown, scanned.markdown);
1375 assert_eq!(indexed.referenced, scanned.referenced);
1376 }
1377 }
1378 }
1379
1380 fn microseconds_per_pass(rounds: usize, passes: usize, mut pass: impl FnMut() -> usize) -> f64 {
1384 let mut observed = 0usize;
1385 let mut best = f64::MAX;
1386 for round in 0..=rounds {
1387 let started = Instant::now();
1388 for _ in 0..passes {
1389 observed = observed.wrapping_add(pass());
1390 }
1391 let elapsed = started.elapsed().as_secs_f64() * 1e6 / passes as f64;
1392 if round > 0 {
1393 best = best.min(elapsed);
1394 }
1395 }
1396 assert!(observed > 0, "the timed transform must produce output");
1397 best
1398 }
1399
1400 #[test]
1401 fn citation_transform_timings_are_informational() {
1402 println!("citation transform, microseconds per pass (best of 3)");
1403 println!(
1404 "{:>10} {:>10} {:>10} {:>12} {:>14}",
1405 "bytes", "citations", "markers", "indexed", "linear scan"
1406 );
1407 for bytes in [1024, 32 * 1024, 256 * 1024] {
1408 for count in [0, 10, 100] {
1409 let refs = numbered_citations(count);
1410 for cited in [true, false] {
1413 if count == 0 && !cited {
1414 continue;
1415 }
1416 let source = answer_of(bytes, if cited { refs.as_slice() } else { &[] });
1417 let markers = source.matches("[[cite:").count();
1418 let passes = (1 << 20usize) / bytes;
1419 let indexed = microseconds_per_pass(3, passes, || {
1420 transform_citations(&source, &refs, true).markdown.len()
1421 });
1422 let scanned = microseconds_per_pass(3, passes, || {
1423 transform_citations_by_linear_scan(&source, &refs, true)
1424 .markdown
1425 .len()
1426 });
1427 println!(
1428 "{bytes:>10} {count:>10} {markers:>10} {indexed:>12.1} {scanned:>14.1}"
1429 );
1430 }
1431 }
1432 }
1433 }
1434
1435 struct CacheProbe {
1436 content: StreamedContent,
1437 citations: Vec<CitationRef>,
1438 interactive: bool,
1439 }
1440
1441 impl gpui::Render for CacheProbe {
1442 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1443 StreamingText::new("cache-probe", &self.content)
1444 .citations(self.citations.clone())
1445 .when(self.interactive, |this| this.on_event(|_, _, _| {}))
1446 }
1447 }
1448
1449 fn transform_calls() -> usize {
1450 TRANSFORM_CALLS.with(std::cell::Cell::get)
1451 }
1452
1453 fn rendered_source() -> SharedString {
1454 RENDERED_SOURCE.with(|rendered| {
1455 rendered
1456 .borrow()
1457 .clone()
1458 .expect("a frame should have rendered the answer")
1459 })
1460 }
1461
1462 fn cache_probe(
1463 cx: &mut TestAppContext,
1464 content: StreamedContent,
1465 ) -> (Entity<CacheProbe>, &mut VisualTestContext) {
1466 cx.update(crate::init);
1467 let (probe, cx) = cx.add_window_view(move |_, _| CacheProbe {
1468 content,
1469 citations: citations().to_vec(),
1470 interactive: true,
1471 });
1472 let cx: &mut VisualTestContext = cx;
1473 cx.update(|window, cx| window.draw(cx).clear(cx));
1474 (probe, cx)
1475 }
1476
1477 fn draw(cx: &mut VisualTestContext) {
1478 cx.update(|window, cx| window.draw(cx).clear(cx));
1479 }
1480
1481 fn cited_answer() -> String {
1482 "Prices rose; see [[cite:pricing]] and [[cite:supplier notes]].".to_owned()
1483 }
1484
1485 #[gpui::test]
1486 fn an_unrelated_re_render_reuses_the_cached_citation_transform(cx: &mut TestAppContext) {
1487 let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1488 let transformed_once = transform_calls();
1489 assert!(transformed_once > 0, "the first frame must transform");
1490 let first_source = rendered_source();
1491
1492 probe.update(cx, |_, cx| cx.notify());
1494 draw(cx);
1495 draw(cx);
1496
1497 assert_eq!(transform_calls(), transformed_once);
1498 assert_eq!(rendered_source(), first_source);
1499 }
1500
1501 #[gpui::test]
1502 fn appending_streamed_text_invalidates_the_cached_transform(cx: &mut TestAppContext) {
1503 let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1504 let before = transform_calls();
1505
1506 probe.update(cx, |probe, cx| {
1507 probe.content.append(" Also [[cite:pricing]] again.");
1508 cx.notify();
1509 });
1510 draw(cx);
1511
1512 assert_eq!(transform_calls(), before + 1);
1513 assert!(rendered_source().contains("again"));
1514 }
1515
1516 #[gpui::test]
1517 fn replacing_text_at_the_same_revision_invalidates_the_cache(cx: &mut TestAppContext) {
1518 let (probe, cx) = cache_probe(
1519 cx,
1520 Progressive::complete("First [[cite:pricing]] answer.".to_owned()),
1521 );
1522 let before = transform_calls();
1523 assert!(rendered_source().contains("First"));
1524
1525 probe.update(cx, |probe, cx| {
1528 probe.content = Progressive::complete("Second [[cite:pricing]] answer.".to_owned());
1529 assert_eq!(probe.content.revision(), 0);
1530 cx.notify();
1531 });
1532 draw(cx);
1533
1534 assert_eq!(transform_calls(), before + 1);
1535 assert!(rendered_source().contains("Second"));
1536 }
1537
1538 #[gpui::test]
1539 fn changing_the_citation_set_invalidates_the_cache(cx: &mut TestAppContext) {
1540 let (probe, cx) = cache_probe(cx, Progressive::complete(cited_answer()));
1541 let before = transform_calls();
1542 assert!(rendered_source().contains("Supplier"));
1543
1544 probe.update(cx, |probe, cx| {
1545 probe.citations = vec![citations()[0].clone()];
1546 cx.notify();
1547 });
1548 draw(cx);
1549
1550 assert_eq!(transform_calls(), before + 1);
1551 let source = rendered_source();
1552 assert!(source.contains("Pricing"));
1553 assert!(source.contains("[[cite:supplier notes]]"));
1554 }
1555
1556 #[gpui::test]
1557 fn changing_interactivity_invalidates_the_cache(cx: &mut TestAppContext) {
1558 let (probe, cx) = cache_probe(cx, Progressive::complete(cited_answer()));
1559 let before = transform_calls();
1560 assert!(rendered_source().contains("gpui-ai-citation://pricing"));
1561
1562 probe.update(cx, |probe, cx| {
1563 probe.interactive = false;
1564 cx.notify();
1565 });
1566 draw(cx);
1567
1568 assert_eq!(transform_calls(), before + 1);
1569 let source = rendered_source();
1570 assert!(!source.contains("gpui-ai-citation://"));
1571 assert!(source.contains("\\[Pricing\\]"));
1572 }
1573
1574 #[gpui::test]
1575 fn a_lifecycle_move_rebuilds_only_the_cursor(cx: &mut TestAppContext) {
1576 let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1577 let before = transform_calls();
1578 let streaming_source = rendered_source();
1579 assert!(streaming_source.ends_with(STREAMING_CURSOR));
1580
1581 probe.update(cx, |probe, cx| {
1582 probe.content.finish();
1583 cx.notify();
1584 });
1585 draw(cx);
1586 assert!(
1587 probe.read_with(cx, |probe, _| probe.content.revision()) > 0,
1588 "finishing must advance the revision"
1589 );
1590
1591 assert_eq!(transform_calls(), before);
1593 let settled_source = rendered_source();
1594 assert_ne!(settled_source, streaming_source);
1595 assert_eq!(
1596 Some(settled_source.as_str()),
1597 streaming_source.strip_suffix(STREAMING_CURSOR)
1598 );
1599
1600 probe.update(cx, |probe, cx| {
1602 probe.content.fail("offline");
1603 cx.notify();
1604 });
1605 draw(cx);
1606 assert_eq!(transform_calls(), before);
1607 assert_eq!(rendered_source(), settled_source);
1608 }
1609
1610 struct CapturedCitationA11y {
1611 role: Option<Role>,
1612 node: accesskit::Node,
1613 }
1614
1615 struct CitationA11yProbe {
1616 captured: Arc<Mutex<Option<CapturedCitationA11y>>>,
1617 }
1618
1619 impl gpui::Render for CitationA11yProbe {
1620 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1621 let captured = self.captured.clone();
1622 canvas(
1623 move |_, window, cx| {
1624 let element = citation_companion_link(
1625 ElementId::from("answer"),
1626 CitationRef::new(
1627 "pricing",
1628 "Pricing",
1629 "Open pricing source",
1630 "app://pricing",
1631 ),
1632 Rc::new(|_, _, _| {}),
1633 cx,
1634 )
1635 .render(window, cx)
1636 .into_element();
1637 let role = element.a11y_role();
1638 let mut node = accesskit::Node::new(role.unwrap_or(Role::Unknown));
1639 element.write_a11y_info(&mut node);
1640 *captured.lock().expect("capture mutex should be available") =
1641 Some(CapturedCitationA11y { role, node });
1642 },
1643 |_, _, _, _| {},
1644 )
1645 }
1646 }
1647
1648 #[gpui::test]
1649 fn companion_link_exposes_role_name_and_click_action(cx: &mut gpui::TestAppContext) {
1650 cx.update(crate::init);
1651 let captured = Arc::new(Mutex::new(None));
1652 let result = captured.clone();
1653 let (_, cx) = cx.add_window_view(move |_, _| CitationA11yProbe { captured });
1654 cx.update(|window, cx| window.draw(cx).clear(cx));
1655
1656 let captured = result
1657 .lock()
1658 .expect("capture mutex should be available")
1659 .take()
1660 .expect("citation link should be captured");
1661 assert_eq!(captured.role, Some(Role::Link));
1662 assert_eq!(captured.node.label(), Some("Open pricing source"));
1663 assert!(captured.node.supports_action(accesskit::Action::Click));
1664 }
1665
1666 struct FollowUpA11yProbe {
1667 captured: Arc<Mutex<Option<CapturedCitationA11y>>>,
1668 }
1669
1670 impl gpui::Render for FollowUpA11yProbe {
1671 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1672 let captured = self.captured.clone();
1673 canvas(
1674 move |_, window, cx| {
1675 let element = follow_up_button(
1676 FollowUp::new("compare", "Compare suppliers"),
1677 Some(Rc::new(|_, _, _| {})),
1678 window,
1679 cx,
1680 )
1681 .render(window, cx)
1682 .into_element();
1683 let role = element.a11y_role();
1684 let mut node = accesskit::Node::new(Role::Unknown);
1685 element.write_a11y_info(&mut node);
1686 *captured.lock().expect("capture mutex should be available") =
1687 Some(CapturedCitationA11y { role, node });
1688 },
1689 |_, _, _, _| {},
1690 )
1691 }
1692 }
1693
1694 #[gpui::test]
1695 fn follow_up_exposes_production_role_name_and_click_action(cx: &mut gpui::TestAppContext) {
1696 cx.update(crate::init);
1697 let captured = Arc::new(Mutex::new(None));
1698 let result = captured.clone();
1699 let (_, cx) = cx.add_window_view(move |_, _| FollowUpA11yProbe { captured });
1700 cx.update(|window, cx| window.draw(cx).clear(cx));
1701
1702 let captured = result
1703 .lock()
1704 .expect("capture mutex should be available")
1705 .take()
1706 .expect("follow-up button should be captured");
1707 assert_eq!(captured.role, Some(Role::Button));
1708 assert_eq!(captured.node.label(), Some("Compare suppliers"));
1709 assert!(captured.node.supports_action(accesskit::Action::Click));
1710 }
1711}