Uh oh!
There was an error while loading. Please reload this page.
feat(web): cite assistant responses with inline citations - #9146
Conversation
Select text in an assistant response and choose "Cite in composer" to insert an inline quote chip at the cursor, with an optional comment bubble anchored at the selected text. Chips carry the quote, its source IDs, and the comment through drafts, stashes, copy/paste, and sending. Clicking a chip navigates to the source response, smooth-scrolls it into view (loading older history when needed), and pulses a highlight on the exact quoted passage that holds briefly before fading. Provider dispatch expands citations into readable quote data for every adapter while persisted messages keep the clickable links; titles, branch names, and stash previews see plain text. Mobile renders sent quotes as blockquotes. The terminal and assistant selection menus share one release-triggered gesture observer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| const hashIndex = href.indexOf("#"); | ||
| if (hashIndex === -1) return null; | ||
| const hash = href.slice(hashIndex + 1); | ||
| if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null; |
There was a problem hiding this comment.
🟡 Mediumlib/assistantCitationNavigation.ts:28
Valid maximum-size citations are rejected when the encoded hash exceeds 140,000 characters, so navigation reaches the destination but the citation cannot be decoded or highlighted. Because the 8,000-unit quote/comment limits can produce a percent-encoded href over 105 KB and Base64url expands it further, raise or remove this cap (while enforcing the citation limits on the decoded value).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/lib/assistantCitationNavigation.ts around line 28:
Valid maximum-size citations are rejected when the encoded hash exceeds 140,000 characters, so navigation reaches the destination but the citation cannot be decoded or highlighted. Because the 8,000-unit quote/comment limits can produce a percent-encoded href over 105 KB and Base64url expands it further, raise or remove this cap (while enforcing the citation limits on the decoded value).
| setReady(null); | ||
| onManualNavigation(); | ||
| } | ||
| if (!viewport || historyLoading) return; |
There was a problem hiding this comment.
🟡 Mediumchat/useAssistantCitationTarget.ts:78
Citations whose source message is missing never complete when the thread is empty: MessagesTimeline does not mount a viewport, so useAssistantCitationTarget returns before the missing-source branch runs, leaving positioning true indefinitely without showing the unavailable-source warning. Perform the history/source lookup without requiring viewport, and only gate list positioning on the viewport being available.
| if(!viewport||historyLoading)return; | |
| if(historyLoading)return; |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/useAssistantCitationTarget.ts around line 78:
Citations whose source message is missing never complete when the thread is empty: `MessagesTimeline` does not mount a viewport, so `useAssistantCitationTarget` returns before the missing-source branch runs, leaving `positioning` true indefinitely without showing the unavailable-source warning. Perform the history/source lookup without requiring `viewport`, and only gate list positioning on the viewport being available.
| <textarea | ||
| ref={inputRef} | ||
| aria-label="Comment on selected text" | ||
| aria-description="Enter to save the citation comment; Shift+Enter for a new line." | ||
| aria-invalid={commentTooLong || undefined} | ||
| placeholder="Add an optional comment..." | ||
| rows={2} | ||
| className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm" |
There was a problem hiding this comment.
This raw <textarea> reconstructs the shared Textarea primitive (field-sizing-content, min-h, text-base sm:text-sm, placeholder color, outline-none) but loses its contract: aria-invalid gets no visual treatment and disabled/focus styling is not shared, so the over-limit state is conveyed only by the helper text. The repo already has a borderless inline comment box built on the primitive — DiffCommentAnnotation uses <Textarea unstyled size="sm" className=... /> and overrides geometry through [&_[data-slot=textarea]]:….
Consider rendering the shared Textarea with unstyled and the same class overrides instead of a hand-rolled control.
Posted via Macroscope — UI Consistency
| } | ||
| > | ||
| <QuoteIcon aria-hidden="true" className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} /> | ||
| <span className={cn(COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]")}>{label}</span> |
There was a problem hiding this comment.
The label always uses the composer label class, which includes select-none, even when the chip renders read-only in a sent message (ChatMarkdown passes no onRemove). Every other chat-rendered chip keeps its label selectable — FileTagChipContent switches to CHAT_INLINE_CHIP_LABEL_CLASS_NAME via its selectable flag, and SkillInlineText uses it directly — so users can select and copy message text (and serializeRenderedMarkdownFragment can pick the chip up from a real selection). Here the quote label drops out of any selection dragged across the message.
Consider mirroring the FileTagChip pattern: import CHAT_INLINE_CHIP_LABEL_CLASS_NAME and pick it when onRemove is undefined, e.g. cn(onRemove ? COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME : CHAT_INLINE_CHIP_LABEL_CLASS_NAME, "max-w-[16em]"). The unconditional contentEditable={false} on the wrapper (line 82) is likewise only needed for the composer variant — ComposerCitationNode's decorator wrapper already sets it.
Posted via Macroscope — UI Consistency
Uh oh!
There was an error while loading. Please reload this page.
| const { message } = entry; | ||
| const isUser = message.role === "user"; | ||
| const renderedText = message.text; | ||
| const renderedText = renderAssistantCitationsAsText(message.text); |
There was a problem hiding this comment.
Mobile renders citations for all messages unnecessarily
Low Severity
renderAssistantCitationsAsText is called unconditionally for every message (both user and assistant roles) at the top of the message rendering block. Assistant messages never contain [Assistant quote](t3-citation://...) links — citations are only inserted into user messages via the composer. For assistant messages, this runs a regex scan across potentially large text for no reason. Gating the call behind isUser avoids unnecessary work on every assistant message render.
Reviewed by Cursor Bugbot for commit 22c1ce8. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This XXL change adds a new cross-platform citation workflow with persistent composer data, virtualized source navigation, provider prompt expansion, and changes to existing terminal selection behavior. The supplied unresolved Medium findings also identify citation-size and unavailable-source handling risks that merit human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
maria-rcks
commented
Sep 2, 2026
Note Written by Refined the citation controls in
Light modeDark modeVerified with 126 focused tests, web typecheck, targeted lint, formatting, and direct light/dark browser checks. Hovering a composer citation shows no tooltip, and selecting the quote or label still opens the source highlight. |
| The quoted text and comment count toward the message limit. | ||
| Select a chip in the composer or a sent message to open the source thread, scroll to the response, | ||
| and highlight the quoted passage — including in older history. The |
There was a problem hiding this comment.
🟡 Mediumuser/composer.md:71
This documentation promises that citations navigate to responses in older history, but useAssistantCitationTarget stops after requestedPages.size >= 20 and reports “Could not load the cited response” even when loadEarlier has more pages, so citations more than 20 history pages back cannot be opened as documented. Qualify or remove the “including in older history” claim.
| and highlight the quoted passage — including in older history. The | |
| and highlight the quoted passage when the source response is available to the citation navigator. The |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/user/composer.md around line 71:
This documentation promises that citations navigate to responses in older history, but `useAssistantCitationTarget` stops after `requestedPages.size >= 20` and reports “Could not load the cited response” even when `loadEarlier` has more pages, so citations more than 20 history pages back cannot be opened as documented. Qualify or remove the “including in older history” claim.
| ## Quote an assistant response | ||
| On web and desktop, select text in an assistant response, then choose **Cite in composer** from the | ||
| menu that appears when you release the selection. This inserts an inline quote chip at your cursor |
There was a problem hiding this comment.
🟢 Lowuser/composer.md:58
Users cannot find the documented Cite in composer control because AssistantSelectionToolbar renders a Cite button, not a menu item with that visible label. Update the instruction to name the visible Cite button (its accessible label is Cite selection in composer).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/user/composer.md around line 58:
Users cannot find the documented **Cite in composer** control because `AssistantSelectionToolbar` renders a **Cite** button, not a menu item with that visible label. Update the instruction to name the visible **Cite** button (its accessible label is `Cite selection in composer`).
Raise the highlight from 28% to 45% of the primary color for both the navigation pulse-and-hold and the comment-editing highlight, so the quoted passage is visible without knowing where to look. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tations # Conflicts: # docs/user/composer.md
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
Uh oh!
There was an error while loading. Please reload this page.
| return ( | ||
| match.index + | ||
| (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset) | ||
| ); |
There was a problem hiding this comment.
🟡 Mediumlib/assistantTextSelection.ts:252
resolveAssistantCitationRange expands a citation ending at the normalized boundary of a multi-character whitespace run to the end of that raw run, so a selection such as "x " in "a x y" highlights "x " instead of the captured text. rawTextOffset treats every offset after the whitespace token's start as a request for the raw token end; at the normalized token boundary it should advance by only the normalized offset (one space), not by match[0].length.
- match.index +- (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset)+ match.index + normalizedOffset - offset🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/lib/assistantTextSelection.ts around lines 252-255:
`resolveAssistantCitationRange` expands a citation ending at the normalized boundary of a multi-character whitespace run to the end of that raw run, so a selection such as `"x "` in `"a x y"` highlights `"x "` instead of the captured text. `rawTextOffset` treats every offset after the whitespace token's start as a request for the raw token end; at the normalized token boundary it should advance by only the normalized offset (one space), not by `match[0].length`.
| attachmentPathLines.length === 0 | ||
| ? parsed.input | ||
| : [parsed.input, attachmentPathLines.join("\n")] | ||
| ? inputTextWithCitations | ||
| : [inputTextWithCitations, attachmentPathLines.join("\n")] | ||
| .filter((part): part is string => typeof part === "string" && part.length > 0) | ||
| .join("\n\n"); |
There was a problem hiding this comment.
🟠 HighLayers/ProviderService.ts:759
adapter.sendTurn receives inputTextWithAttachmentPaths without validating its final length, so expanded citation text at the 120,000-character limit can become an oversized prompt when resolvable attachment path lines are appended. Validate the joined inputTextWithAttachmentPaths after constructing it.
| attachmentPathLines.length===0 | |
| ? parsed.input | |
| : [parsed.input,attachmentPathLines.join("\n")] | |
| ? inputTextWithCitations | |
| : [inputTextWithCitations,attachmentPathLines.join("\n")] | |
| .filter((part): part is string=>typeofpart==="string"&&part.length>0) | |
| .join("\n\n"); | |
| constinputTextWithAttachmentPaths= | |
| attachmentPathLines.length===0 | |
| ? inputTextWithCitations | |
| : [inputTextWithCitations,attachmentPathLines.join("\n")] | |
| .filter((part): part is string=>typeofpart==="string"&&part.length>0) | |
| .join("\n\n"); | |
| yield*decodeInputOrValidationError({ | |
| operation: "ProviderService.sendTurn", | |
| schema: ProviderSendTurnInput.fields.input, | |
| payload: inputTextWithAttachmentPaths, | |
| }); |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/Layers/ProviderService.ts around lines 759-763:
`adapter.sendTurn` receives `inputTextWithAttachmentPaths` without validating its final length, so expanded citation text at the 120,000-character limit can become an oversized prompt when resolvable attachment path lines are appended. Validate the joined `inputTextWithAttachmentPaths` after constructing it.
| let separator = false; | ||
| const visit = (node: Node) => { | ||
| if (node.nodeType === 3) { | ||
| const text = node as Text; | ||
| if (text.length === 0) return; | ||
| if (separator && length > 0) { | ||
| parts.push("\n"); | ||
| length += 1; | ||
| } | ||
| separator = false; | ||
| chunks.push({ node: text, start: length, end: length + text.length }); | ||
| parts.push(text.data); | ||
| length += text.length; | ||
| return; | ||
| } | ||
| if (node.nodeType !== 1) return; | ||
| const element = node as Element; | ||
| if (element.matches(EXCLUDED_SELECTOR)) return; | ||
| const block = element.matches(BLOCK_SELECTOR); | ||
| if (block || element.tagName === "BR") separator = true; | ||
| for (const child of element.childNodes) visit(child); | ||
| if (block) separator = true; | ||
| }; |
There was a problem hiding this comment.
🟡 Mediumlib/assistantTextSelection.ts:144
When a selection crosses consecutive <br> elements, readAssistantText returns only one \n instead of one per break, so the saved quote and offsets no longer represent the text the user selected. Because separator is a boolean, later breaks overwrite the pending separator; track the number of consecutive breaks and emit that many separators.
- let separator = false;+ let separator = 0;
@@
- if (separator && length > 0) {+ if (separator > 0 && length > 0) {
parts.push("\n");
- length += 1;+ length += separator;
}
- separator = false;+ separator = 0;
@@
- if (block || element.tagName === "BR") separator = true;+ if (element.tagName === "BR") separator += 1;+ else if (block) separator = Math.max(separator, 1);
@@
- if (block) separator = true;+ if (block) separator = Math.max(separator, 1);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/lib/assistantTextSelection.ts around lines 144-167:
When a selection crosses consecutive `<br>` elements, `readAssistantText` returns only one `\n` instead of one per break, so the saved quote and offsets no longer represent the text the user selected. Because `separator` is a boolean, later breaks overwrite the pending separator; track the number of consecutive breaks and emit that many separators.
| const decodeCitation = Schema.decodeUnknownOption(AssistantCitation); | ||
| function encodePathPart(value: string): string { | ||
| return encodeURIComponent(value).replace( |
There was a problem hiding this comment.
🟡 Mediumsrc/assistantCitations.ts:21
formatAssistantCitationHref emits IDs such as . and .. as literal path segments, so new URL() normalizes them and parseAssistantCitationHref returns null instead of the original citation. Sources with these valid IDs are therefore serialized but cannot be collected, expanded, or navigated; percent-encode . in encodePathPart as well.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/assistantCitations.ts around line 21:
`formatAssistantCitationHref` emits IDs such as `.` and `..` as literal path segments, so `new URL()` normalizes them and `parseAssistantCitationHref` returns `null` instead of the original citation. Sources with these valid IDs are therefore serialized but cannot be collected, expanded, or navigated; percent-encode `.` in `encodePathPart` as well.
| setupCleanups.push(() => { | ||
| window.removeEventListener("mouseup", handleMouseUp); | ||
| mount.removeEventListener("pointerdown", handlePointerDown); | ||
| selectionActions = observeSelectionActions({ |
There was a problem hiding this comment.
🟡 Mediumcomponents/ThreadTerminalDrawer.tsx:778
In browser mode, clicking Copy or Add-to-chat in the selection menu never executes the action: observeSelectionActions treats the fallback menu's DOM pointerdown outside mount as an interaction, invalidates the request, and closes the menu before its click resolves. Pass the menu element through getActionElement (or otherwise exempt the fallback menu) so interactions with the menu remain part of the active selection flow.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/ThreadTerminalDrawer.tsx around line 778:
In browser mode, clicking `Copy` or `Add-to-chat` in the selection menu never executes the action: `observeSelectionActions` treats the fallback menu's DOM pointerdown outside `mount` as an interaction, invalidates the request, and closes the menu before its click resolves. Pass the menu element through `getActionElement` (or otherwise exempt the fallback menu) so interactions with the menu remain part of the active selection flow.
| onCancel: () => void; | ||
| }) { | ||
| const [comment, setComment] = useState(citation.comment ?? ""); | ||
| const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; |
There was a problem hiding this comment.
🟡 Mediumchat/AssistantCitationCommentEditor.tsx:18
Save is disabled for a comment whose trimmed value is at most ASSISTANT_CITATION_MAX_COMMENT_LENGTH, such as an 8,000-character comment with one leading or trailing space. commentTooLong checks the untrimmed value, while withAssistantCitationComment trims before persisting; validate comment.trim() so the editor uses the same value as the storage contract.
- const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH;+ const commentTooLong = comment.trim().length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/AssistantCitationCommentEditor.tsx around line 18:
`Save` is disabled for a comment whose trimmed value is at most `ASSISTANT_CITATION_MAX_COMMENT_LENGTH`, such as an 8,000-character comment with one leading or trailing space. `commentTooLong` checks the untrimmed value, while `withAssistantCitationComment` trims before persisting; validate `comment.trim()` so the editor uses the same value as the storage contract.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Reviewed by Cursor Bugbot for commit 393a909. Configure here.
| dismissed = true; | ||
| } | ||
| onDismiss("cancel"); | ||
| }; |
There was a problem hiding this comment.
Scroll cancels the Cite toolbar
Medium Severity
observeSelectionActions treats any descendant scroll as a cancel. After pointerup, pointerDown is already false, so a selection scroll-into-view or LegendList layout scroll clears the pending timer and sets dismissed. selectionchange will not recover, so the Cite control never appears for selections that move the timeline.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 393a909. Configure here.
| const hashIndex = href.indexOf("#"); | ||
| if (hashIndex === -1) return null; | ||
| const hash = href.slice(hashIndex + 1); | ||
| if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null; |
There was a problem hiding this comment.
Large citation hashes fail navigation
Low Severity
assistantCitationFromLocation drops hashes longer than 140,000 characters, but assistantCitationHash base64url-encodes the full t3-citation href. A near-max Unicode quote plus comment encodes past that cap, so chip clicks still change the thread route and then never activate highlight or history loading.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 393a909. Configure here.
Adopts main's round-19 features into the v2 stack: payload-budgeted orchestration replay (#8992), sidebar row subscription leases (#9052), tool group virtualization and scroll anchoring (#9106), repeated-command and browser-group presentation, inline assistant citations (#9146), per-cwd provider skills discovery (#8778), Claude composer skill dispatch (#9128), grok health probe and model negotiation (#9154), and the failed-tool thinking fallback (#9165). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts main's round-19 features into the v2 stack: payload-budgeted orchestration replay (#8992), sidebar row subscription leases (#9052), tool group virtualization and scroll anchoring (#9106), repeated-command and browser-group presentation, inline assistant citations (#9146), per-cwd provider skills discovery (#8778), Claude composer skill dispatch (#9128), grok health probe and model negotiation (#9154), and the failed-tool thinking fallback (#9165). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>




Quoting part of an assistant response meant copying text that lost all connection to where it came from. This adds citations: select text in an assistant response, choose Cite in composer, and an inline quote chip lands at your cursor — with an optional comment bubble anchored right at the selected passage. Clicking a chip (in the draft or a sent message) navigates to the source response, smooth-scrolls it into view — loading older history and unfolding turns when needed — and pulses a highlight on the exact quoted text that holds for a moment before fading.
How it works:
t3-citation://Markdown links carrying the quote, source IDs, offsets, context, and optional comment — no new tables, migrations, or sidecar draft state. They survive drafts, stashes, copy/paste, reloads, and sending.ProviderService.sendTurnexpands citations into readable quote data for every adapter (quote marked as reference material, comment as user-authored), while persisted messages keep the clickable links. Title/branch generation and stash previews see plain text.docs/user/composer.mdanddocs/internals/assistant-citations.md.Demo
Selection → cite → comment bubble at the selection → chip in composer → click-to-source with the pulse-and-hold highlight:
https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/35ecd10e3b53aa45/citation-demo.mp4
Built with Claude Fable 5 (Codex CLI + Claude Code).
🤖 Generated with Claude Code
Note
Medium Risk
Changes the provider turn input pipeline (citation expansion and re-validation against send limits) and orchestration title/branch inputs, though malformed citations pass through and expansion is covered by tests.
Overview
Users can quote assistant messages end-to-end: select text in the web timeline, insert an inline citation chip in the Lexical composer (optional comment, paste/copy/undo-safe), and follow chips back to the source message with history loading, turn expansion, scroll, and a temporary highlight.
Citations are stored as
t3-citation://markdown links in message text and drafts. Server-side,ProviderService.sendTurnexpands them into numbered quote placeholders plus a structuredassistant_citationsJSON block for adapters while keeping the canonical links in persisted messages; title/branch generation and stash previews use plain-text citation content. Mobile only renders citations as readable text in the feed.Chat markdown renders citation links as chips; composer submission validates both encoded and expanded prompt length. The terminal drawer reuses a shared
observeSelectionActionshelper for selection menus (replacing bespoke mouseup timing).Reviewed by Cursor Bugbot for commit 393a909. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add inline citations for assistant responses with selection, composer chips, and provider expansion
t3-citationURIs with bounded identifiers, quote text, offsets, and context;parseAssistantCitationHrefandcollectAssistantCitationsin assistantCitations.ts validate and collect them while leaving malformed links untouchedexpandAssistantCitationsForProviderreplaces serialized citation links with numbered quote references plus a structuredassistant_citationscontext block;ProviderService.sendTurnin ProviderService.ts runs expansion before adapter dispatch and re-validates expanded input against the provider limituseAssistantCitationTargetandMessagesTimelineloads missing history, expands collapsed turns, scrolls to the source, highlights it with CSS custom highlights, and warns on unavailable sources; title and branch-name generation in ProviderCommandReactor.ts now receive plain-text citation content instead of serialized markuprenderAssistantCitationsAsTextin ThreadFeed.tsx; terminal selection handling in ThreadTerminalDrawer.tsx moves to the sharedobserveSelectionActionsobserver with ownership-aware menu dismissalgetComposerPromptLengthValidationMessagein composerSubmission.ts now rejects drafts when either serialized or expanded citation text exceeds the provider limit;CHAT_MARKDOWN_SANITIZE_SCHEMAin ChatMarkdown.tsx now permits thet3-citationprotocol in hrefsMacroscope summarized 393a909.