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", () => ({
1919 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
2020}));
2121// 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}));
2722vi.mock("../ui/popover", () => ({
2823 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
2924 PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,
......@@ -62,6 +57,7 @@ function mount(onSave = vi.fn(() => true)) {
6257 return (
6358 <AssistantCitationChip
6459 citation={citation}
60 composer
6561 commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
6662 />
6763 );
apps/web/src/components/chat/AssistantCitationChip.tsx+59-21
......@@ -4,7 +4,7 @@ import {
44 serializeAssistantCitation,
55} from "@t3tools/shared/assistantCitations";
66import { Link, useNavigate } from "@tanstack/react-router";
7import { PencilIcon, QuoteIcon } from "lucide-react";
7import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
88import {
99 useEffect,
1010 useEffectEvent,
......@@ -20,9 +20,12 @@ import {
2020 assistantCitationHash,
2121 assistantCitationNavigation,
2222} from "../../lib/assistantCitationNavigation";
23import { cn } from "~/lib/utils";
2324import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
25import { ContextChipPopover } from "../contextChipParts";
26import { Button } from "../ui/button";
27import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
28import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
2629import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
2730import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
2831import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
......@@ -122,16 +125,27 @@ export function AssistantCitationChip({
122125 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
123126 </Link>
124127 );
125 const chatSourceLink = (
126 <Link
127 {...sourceLinkProps}
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"
129 aria-label={`View cited assistant text: ${label}`}
130 >
131 <QuoteIcon aria-hidden="true" />
132 <ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
133 </Link>
134 );
128 if (!composer) {
129 return (
130 <ContextChipPopover
131 kind="citation"
132 icon={<QuoteIcon />}
133 label={label}
134 accessibleLabel={`Quoted assistant text: ${label}`}
135 copyMarkdown={serializeAssistantCitation(citation)}
136 >
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 }
135149 return (
136150 <ContextChip
137151 kind="citation"
......@@ -139,14 +153,7 @@ export function AssistantCitationChip({
139153 data-assistant-citation-chip="true"
140154 data-markdown-copy={serializeAssistantCitation(citation)}
141155 >
142 {composer ? (
143 composerSourceLink
144 ) : (
145 <Tooltip>
146 <TooltipTrigger render={chatSourceLink} />
147 <TooltipPopup side="top">View source</TooltipPopup>
148 </Tooltip>
149 )}
156 {composerSourceLink}
150157 {commentEditor ? (
151158 <Popover
152159 open={commentEditor.open}
......@@ -232,3 +239,34 @@ export function AssistantCitationChip({
232239 </ContextChip>
233240 );
234241}
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}