authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-07 22:49:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 02:49:53-03:00
log12865b4a73c23bb402717d5f592dcd954ed196e5
treebff3b58c1f80453b9a88cbe434c3d788bcddc0a9
parent33afd5a5aa361890c9ce22479e8f52f6b18248aa
signature Signed by PGP key B5690EEEBB952194

feat(web): quote chips show what you said about the quote (#15703)


2 files changed, 60 insertions(+), 26 deletions(-)

apps/web/src/components/chat/AssistantCitationChip.test.tsx+1-5
...@@ -19,11 +19,6 @@ vi.mock("@tanstack/react-router", () => ({...@@ -19,11 +19,6 @@ vi.mock("@tanstack/react-router", () => ({
19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
20}));20}));
21// Keep the real chip/editor lifecycle while replacing DOM positioning and floating layers.21// Keep the real chip/editor lifecycle while replacing DOM positioning and floating layers.
22vi.mock("../ui/tooltip", () => ({
23 Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
24 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
25 TooltipPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
26}));
27vi.mock("../ui/popover", () => ({22vi.mock("../ui/popover", () => ({
28 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,23 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
29 PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,24 PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,
...@@ -62,6 +57,7 @@ function mount(onSave = vi.fn(() => true)) {...@@ -62,6 +57,7 @@ function mount(onSave = vi.fn(() => true)) {
62 return (57 return (
63 <AssistantCitationChip58 <AssistantCitationChip
64 citation={citation}59 citation={citation}
60 composer
65 commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}61 commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
66 />62 />
67 );63 );
apps/web/src/components/chat/AssistantCitationChip.tsx+59-21
...@@ -4,7 +4,7 @@ import {...@@ -4,7 +4,7 @@ import {
4 serializeAssistantCitation,4 serializeAssistantCitation,
5} from "@t3tools/shared/assistantCitations";5} from "@t3tools/shared/assistantCitations";
6import { Link, useNavigate } from "@tanstack/react-router";6import { Link, useNavigate } from "@tanstack/react-router";
7import { PencilIcon, QuoteIcon } from "lucide-react";7import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
8import {8import {
9 useEffect,9 useEffect,
10 useEffectEvent,10 useEffectEvent,
...@@ -20,9 +20,12 @@ import {...@@ -20,9 +20,12 @@ import {
20 assistantCitationHash,20 assistantCitationHash,
21 assistantCitationNavigation,21 assistantCitationNavigation,
22} from "../../lib/assistantCitationNavigation";22} from "../../lib/assistantCitationNavigation";
23import { cn } from "~/lib/utils";
23import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";24import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";25import { ContextChipPopover } from "../contextChipParts";
25import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";26import { Button } from "../ui/button";
27import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
28import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
26import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";29import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
27import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";30import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
28import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";31import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
...@@ -122,16 +125,27 @@ export function AssistantCitationChip({...@@ -122,16 +125,27 @@ export function AssistantCitationChip({
122 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>125 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
123 </Link>126 </Link>
124 );127 );
125 const chatSourceLink = (128 if (!composer) {
126 <Link129 return (
127 {...sourceLinkProps}130 <ContextChipPopover
128 className="inline-flex h-full min-w-0 items-center gap-[0.33em] rounded-sm text-inherit no-underline hover:bg-(--context-chip-accent)/17 focus-visible:outline-2 focus-visible:outline-foreground"131 kind="citation"
129 aria-label={`View cited assistant text: ${label}`}132 icon={<QuoteIcon />}
130 >133 label={label}
131 <QuoteIcon aria-hidden="true" />134 accessibleLabel={`Quoted assistant text: ${label}`}
132 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>135 copyMarkdown={serializeAssistantCitation(citation)}
133 </Link>136 >
134 );137 <div className="flex max-h-[calc(var(--available-height)_-_1rem_-_2px)] flex-col items-start gap-3 p-1 text-sm">
138 <AssistantCitationQuote citation={citation} />
139 <PopoverClose
140 render={<Button variant="outline" size="sm" render={<Link {...sourceLinkProps} />} />}
141 >
142 <ArrowUpRightIcon aria-hidden="true" />
143 Go to source
144 </PopoverClose>
145 </div>
146 </ContextChipPopover>
147 );
148 }
135 return (149 return (
136 <ContextChip150 <ContextChip
137 kind="citation"151 kind="citation"
...@@ -139,14 +153,7 @@ export function AssistantCitationChip({...@@ -139,14 +153,7 @@ export function AssistantCitationChip({
139 data-assistant-citation-chip="true"153 data-assistant-citation-chip="true"
140 data-markdown-copy={serializeAssistantCitation(citation)}154 data-markdown-copy={serializeAssistantCitation(citation)}
141 >155 >
142 {composer ? (156 {composerSourceLink}
143 composerSourceLink
144 ) : (
145 <Tooltip>
146 <TooltipTrigger render={chatSourceLink} />
147 <TooltipPopup side="top">View source</TooltipPopup>
148 </Tooltip>
149 )}
150 {commentEditor ? (157 {commentEditor ? (
151 <Popover158 <Popover
152 open={commentEditor.open}159 open={commentEditor.open}
...@@ -232,3 +239,34 @@ export function AssistantCitationChip({...@@ -232,3 +239,34 @@ export function AssistantCitationChip({
232 </ContextChip>239 </ContextChip>
233 );240 );
234}241}
242
243function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) {
244 const [fade, setFade] = useState({ top: false, bottom: false });
245 const updateFade = (element: HTMLElement) => {
246 const top = element.scrollTop > 1;
247 const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1;
248 setFade((current) =>
249 current.top === top && current.bottom === bottom ? current : { top, bottom },
250 );
251 };
252 return (
253 <div
254 ref={(element) => {
255 if (!element) return;
256 const observer = new ResizeObserver(() => updateFade(element));
257 observer.observe(element);
258 return () => observer.disconnect();
259 }}
260 onScroll={(event) => updateFade(event.currentTarget)}
261 className={cn(
262 "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word",
263 getVirtualizedScrollFadeClassName(fade),
264 )}
265 >
266 <blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
267 {citation.text}
268 </blockquote>
269 {citation.comment ? <p>{citation.comment}</p> : null}
270 </div>
271 );
272}