From 4a5d8855ab8fa31a9093dce49aae5dd75bc3f1cc Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:00:59 +0000 Subject: [PATCH 1/3] feat(web): a quote in a sent message shows as an inset card A quoted response in a sent message was a chip that opened a popover. It is now a two-line card inside the bubble with the quoted passage highlighted in its surrounding text, the comment under it, and a click that opens the source. Copying the message copies each quote once, and find matches the comment instead of the hidden quote. --- apps/web/src/components/ChatMarkdown.tsx | 23 ++- .../components/ComposerPromptEditorTiptap.tsx | 1 - .../AssistantCitationChip.browser.test.tsx | 1 - .../components/chat/AssistantCitationChip.tsx | 151 +++++++++--------- .../components/chat/MessagesTimeline.logic.ts | 9 +- packages/shared/src/threadFindText.test.ts | 6 +- packages/shared/src/threadFindText.ts | 5 +- 7 files changed, 107 insertions(+), 89 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0b0116be549cb..8b30a8b66c01d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -94,7 +94,7 @@ import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { AssistantCitationEmbed } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { mathKind } from "@t3tools/shared/markdownMath"; import remarkMath from "remark-math"; @@ -952,6 +952,15 @@ const ChatMarkdownRendererContext = React.createContext< // Isolate streamed source text from the renderer context; only offset-based renderers need it. const ChatMarkdownTextContext = React.createContext(""); +// A quote card is a block. These say nothing else shares its paragraph or list item on that side. +const LIST_MARKER = String.raw`(?:[-*+]|\d+[.)])[ \t]+`; +const CITATION_LEADS_BLOCK = new RegExp( + String.raw`(?:(?:^|\n[ \t]*\n)[ \t>]*(?:${LIST_MARKER})?|\n[ \t>]*${LIST_MARKER})$`, +); +const CITATION_ENDS_BLOCK = new RegExp( + String.raw`^[ \t]*(?:$|\n[ \t]*(?:\n|$)|\n[ \t>]*${LIST_MARKER})`, +); + function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { const Tag = `h${level}` as const; return function MarkdownHeading({ @@ -1096,7 +1105,17 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); const citation = href ? parseAssistantCitationHref(href) : null; - if (citation) return ; + if (citation) { + const before = node?.position?.start.offset; + const after = node?.position?.end.offset; + return ( + + ); + } const threadLink = href ? parseThreadLinkHref(href) : null; if (threadLink) { const written = hastPlainTextDeep(node); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..40accf71b219d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -523,7 +523,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { > ); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 35935171f46ab..4cc400bea6fdb 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -5,7 +5,7 @@ import { serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; -import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; +import { PencilIcon, QuoteIcon } from "lucide-react"; import { useEffect, useEffectEvent, @@ -24,24 +24,91 @@ import { import { cn } from "~/lib/utils"; import { useQuietThreadShell } from "~/state/entities"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; -import { ContextChipPopover } from "../contextChipParts"; -import { Button } from "../ui/button"; -import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; -import { observeResize } from "~/lib/observeResize"; import { textDirection } from "~/textDirection"; +function useCitationSourceLinkProps(citation: AssistantCitation) { + const navigate = useNavigate(); + const sourceThread = useQuietThreadShell( + scopeThreadRef(citation.environmentId, citation.threadId), + ); + return { + ...assistantCitationRoute(citation, sourceThread), + "data-markdown-copy": serializeAssistantCitation(citation), + onClick: (event: ReactMouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + void navigate(assistantCitationNavigation(citation, sourceThread)); + }, + }; +} + +/** + * A quoted response inside a message. Clicking it goes to the source; its comment follows as + * text. `leading` and `trailing` say it starts or ends its block, which already has spacing. + */ +export function AssistantCitationEmbed({ + citation, + leading, + trailing, +}: { + citation: AssistantCitation; + leading: boolean; + trailing: boolean; +}) { + const wholeResponse = citation.prefix === "" && citation.suffix === ""; + const flat = (text: string) => text.replace(/\s+/g, " "); + const { comment, ...quoted } = citation; + const { "data-markdown-copy": markdownCopy, ...linkProps } = useCitationSourceLinkProps(citation); + return ( + // One copy boundary for the card and its comment, so a copied quote carries the comment once. + + + + {wholeResponse ? ( + flat(citation.text) + ) : ( + <> + {citation.prefix ? `…${flat(citation.prefix)}` : null} + + {flat(citation.text)} + + {citation.suffix ? `${flat(citation.suffix)}…` : null} + + )} + + + {comment ? ( + + {comment} + + ) : null} + + ); +} + +/** The citation chip in the composer, with its comment editor. */ export function AssistantCitationChip({ citation, - composer = false, commentEditor, }: { citation: AssistantCitation; - composer?: boolean; commentEditor?: { open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; @@ -53,10 +120,7 @@ export function AssistantCitationChip({ onRestoreFocus?: () => void; }; }) { - const navigate = useNavigate(); - const sourceThread = useQuietThreadShell( - scopeThreadRef(citation.environmentId, citation.threadId), - ); + const sourceLinkProps = useCitationSourceLinkProps(citation); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); @@ -116,17 +180,6 @@ export function AssistantCitationChip({ } : undefined; const label = assistantCitationLabel(citation); - const sourceLinkProps = { - ...assistantCitationRoute(citation, sourceThread), - "data-markdown-copy": serializeAssistantCitation(citation), - onClick: (event: ReactMouseEvent) => { - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - event.preventDefault(); - void navigate(assistantCitationNavigation(citation, sourceThread)); - }, - }; const composerSourceLink = ( ); - if (!composer) { - return ( - } - label={label} - accessibleLabel={`Quoted assistant text: ${label}`} - copyMarkdown={serializeAssistantCitation(citation)} - > -
- - } />} - > - -
-
- ); - } return ( ); } - -function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { - const [fade, setFade] = useState({ top: false, bottom: false }); - const updateFade = (element: HTMLElement) => { - const top = element.scrollTop > 1; - const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; - setFade((current) => - current.top === top && current.bottom === bottom ? current : { top, bottom }, - ); - }; - const observeFade = (element: HTMLDivElement | null) => - element ? observeResize(element, () => updateFade(element)) : undefined; - return ( -
updateFade(event.currentTarget)} - className={cn( - "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", - getVirtualizedScrollFadeClassName(fade), - )} - > -
- {citation.text} -
- {citation.comment ?

{citation.comment}

: null} -
- ); -} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4536e9a250386..ab6638ac52936 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,10 +3,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; -import { - assistantCitationLabel, - collectAssistantCitations, -} from "@t3tools/shared/assistantCitations"; +import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { @@ -2654,7 +2651,9 @@ function visibleUserMessageText(text: string): string { let visible = ""; let cursor = 0; for (const match of collectAssistantCitations(text)) { - visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + // A quote shows as a two-line card with its comment under it. + visible += linkLabels(text.slice(cursor, match.start)); + visible += "\n\n" + (match.citation.comment ?? "") + "\n"; cursor = match.end; } return visible + linkLabels(text.slice(cursor)); diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 77a0874c21336..2976559316a45 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -96,7 +96,7 @@ it("indexes nested disclosure summaries and bodies in rendered order", () => { ).toEqual(["Outer", "first", "Inner", "second"]); }); -it("indexes the citation chip label instead of its link text", () => { +it("indexes a citation by its comment, not its quote or link text", () => { const citation = { version: 1 as const, environmentId: EnvironmentId.make("environment"), @@ -110,9 +110,9 @@ it("indexes the citation chip label instead of its link text", () => { }; const text = `Please fix ${serializeAssistantCitation(citation)} thanks`; expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([ - "Please fix cited needle thanks", + "Please fix thanks", ]); - const commented = serializeAssistantCitation({ ...citation, comment: "my note" }); + const commented = serializeAssistantCitation({ ...citation, comment: "my\nnote" }); expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([ "my note", ]); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 3afec6e04729e..fd315a293cca4 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -25,7 +25,7 @@ import { isAttachmentOnlyBootstrapPrompt, parseComposerContextHref, } from "./composerContextReferences.ts"; -import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; +import { parseAssistantCitationHref } from "./assistantCitations.ts"; import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; import { unified } from "unified"; @@ -141,7 +141,8 @@ function markdownThreadFindText( const citation = node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; if (citation) { - text += assistantCitationLabel(citation); + // The quote card is not searched; its text is found in the message it came from. + text += (citation.comment ?? "").replace(/\r?\n/g, " "); return; } { From 4841735ee33de689c3a5978ca4ba2fac9730e9a2 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:14:35 +0000 Subject: [PATCH 2/3] fix(web): find and collapse count a quote card the way it renders Find indexed the text before a quote together with the quote's comment, so a query spanning both counted a match it could not highlight. A message made only of uncommented quotes had no visible text and never collapsed. --- .../components/chat/MessagesTimeline.logic.test.ts | 13 ++++++++----- .../src/components/chat/MessagesTimeline.logic.ts | 4 ++-- packages/shared/src/threadFindText.test.ts | 9 +++++---- packages/shared/src/threadFindText.ts | 3 ++- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ba0f508ebeecd..8152deef89f42 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4538,22 +4538,25 @@ describe("subagent continued by a later turn", () => { }); describe("shouldCollapseUserMessage", () => { - it("measures a quote chip by its label, not its encoded link", () => { + it("measures a quote by its card, not its encoded link", () => { const quote = "A long assistant paragraph that the user quoted. ".repeat(40); - const citation = serializeAssistantCitation({ - version: 1, + const source = { + version: 1 as const, environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), messageId: MessageId.make("source"), text: quote, - comment: "Why does this matter?", start: 0, end: quote.length, prefix: "", suffix: "", - }); + }; + const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" }); + const bare = serializeAssistantCitation(source); expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false); + expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ab6638ac52936..cd3e4eeec5c9f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2651,9 +2651,9 @@ function visibleUserMessageText(text: string): string { let visible = ""; let cursor = 0; for (const match of collectAssistantCitations(text)) { - // A quote shows as a two-line card with its comment under it. + // A quote shows as a two-line card, counted here as a mark, with its comment under it. visible += linkLabels(text.slice(cursor, match.start)); - visible += "\n\n" + (match.citation.comment ?? "") + "\n"; + visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n"; cursor = match.end; } return visible + linkLabels(text.slice(cursor)); diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 2976559316a45..ea3604c4e1610 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -110,10 +110,11 @@ it("indexes a citation by its comment, not its quote or link text", () => { }; const text = `Please fix ${serializeAssistantCitation(citation)} thanks`; expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([ - "Please fix thanks", + "Please fix ", + " thanks", ]); const commented = serializeAssistantCitation({ ...citation, comment: "my\nnote" }); - expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([ - "my note", - ]); + expect( + searchableMessageSegments({ role: "user", text: `fix ${commented} thanks`, streaming: false }), + ).toEqual(["fix ", "my note thanks"]); }); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index fd315a293cca4..8d235e161e94d 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -141,7 +141,8 @@ function markdownThreadFindText( const citation = node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; if (citation) { - // The quote card is not searched; its text is found in the message it came from. + // The quote card is not searched, and it splits the text before it from its comment. + flush(); text += (citation.comment ?? "").replace(/\r?\n/g, " "); return; } From 2589f5edf33a0aafad5c8e6849af6df3610c9e42 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:20:08 +0000 Subject: [PATCH 3/3] fix(web): copying a selection inside a quote card keeps the quote link A selection that covered only a card and its comment cloned neither the card's copy boundary nor a link that carried one, so the copy lost the quote's source. --- apps/web/src/components/chat/AssistantCitationChip.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4cc400bea6fdb..803779b995d52 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -65,15 +65,17 @@ export function AssistantCitationEmbed({ const wholeResponse = citation.prefix === "" && citation.suffix === ""; const flat = (text: string) => text.replace(/\s+/g, " "); const { comment, ...quoted } = citation; - const { "data-markdown-copy": markdownCopy, ...linkProps } = useCitationSourceLinkProps(citation); + const linkProps = useCitationSourceLinkProps(citation); return ( // One copy boundary for the card and its comment, so a copied quote carries the comment once.