From 5fdc6cb45fd943bdcd38ff3b72fa1b4821f17586 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 10:38:56 -0500
Subject: [PATCH 01/13] feat(web): mark draft citations in assistant responses
---
apps/web/src/components/ChatView.tsx | 6 +
...ditorTiptap.citationDraft.browser.test.tsx | 18 ++-
.../components/ComposerPromptEditorTiptap.tsx | 9 ++
.../chat/AssistantCitationMarkers.tsx | 107 ++++++++++++++++++
apps/web/src/components/chat/ChatComposer.tsx | 3 +
.../src/components/chat/MessagesTimeline.tsx | 13 +++
.../assistantCitationMarkers.logic.test.ts | 29 +++++
.../chat/assistantCitationMarkers.logic.ts | 18 +++
docs/user/composer.md | 2 +
9 files changed, 204 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/components/chat/AssistantCitationMarkers.tsx
create mode 100644 apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
create mode 100644 apps/web/src/components/chat/assistantCitationMarkers.logic.ts
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index ff4a642b5c31d..4f73de39cf9db 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -579,6 +579,10 @@ export default function ChatView(props: ChatViewProps) {
: null;
})() as AssistantCitationRequest | null;
const citationHistory = useCitedHistory(citationRequest);
+ const openDraftCitation = useStableCallback(
+ (index: number, anchor: AssistantCitationSourceAnchor) =>
+ composerRef.current?.openCitation(index, anchor),
+ );
const { resolvedTheme } = useTheme();
const composerRuntimeMode = useComposerDraftStore(
(store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null,
@@ -3440,6 +3444,8 @@ export default function ChatView(props: ChatViewProps) {
citationRequest={citationRequest}
citationHistory={citationHistory}
onCiteAssistantText={citeAssistantTextStable}
+ citationDraftTarget={composerDraftTarget}
+ onOpenDraftCitation={openDraftCitation}
{...(activeProject
? { onRunShellCommand: parentPanel?.runShellCommand ?? runShellCommandStable }
: {})}
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
index a1a69a7968ee9..918364d9570ed 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
@@ -15,6 +15,8 @@ import {
type ComposerPromptEditorHandle,
} from "./ComposerPromptEditorTiptap";
+import { AssistantCitationMarkers } from "./chat/AssistantCitationMarkers";
+
const citation = {
version: 1 as const,
environmentId: EnvironmentId.make("environment"),
@@ -35,11 +37,12 @@ function Composer({
question: boolean;
editorRef: React.RefObject;
}) {
+ const [viewport, setViewport] = useState(null);
const [prompt, setPrompt] = useState(promptBefore);
const [answer, setAnswer] = useState("");
return (
<>
-
+
hello world
+
editorRef.current?.openCitation(index, anchor)}
+ />
{
+ const editorRef = createRef();
+ await using screen = await render();
+ await userEvent.click(screen.getByRole("button", { name: "Edit citation 1 comment" }));
+ await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
+ expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore);
+});
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 8c4698210b1d5..765027e028f7c 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -135,6 +135,7 @@ export interface ComposerPromptEditorHandle {
focusAt: (cursor: number) => void;
focusAtEnd: () => void;
readSelectionRange: () => { start: number; end: number };
+ openCitation: (index: number, sourceAnchor: AssistantCitationSourceAnchor) => void;
requestCitationComment: (request: ComposerCitationCommentRequest) => void;
readSnapshot: () => {
value: string;
@@ -1639,6 +1640,14 @@ function ComposerPromptEditorTiptapInner(
readSnapshot();
return selectionRangeRef.current;
},
+ openCitation: (index, sourceAnchor) => {
+ if (!editor) return;
+ const node = findChildren(
+ editor.state.doc,
+ (node) => node.type.name === "composer-citation",
+ )[index]?.node;
+ if (node) setOpenCitation({ key: node.attrs.citeKey, sourceAnchor });
+ },
requestCitationComment: (request) => {
citationRequestRef.current = request;
if (!editor) return;
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
new file mode 100644
index 0000000000000..fd2e1761a8c3c
--- /dev/null
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -0,0 +1,107 @@
+import { useComposerDraftStore, type ComposerThreadTarget } from "~/composerDraftStore";
+import { draftCitationMarkers } from "./assistantCitationMarkers.logic";
+import { useLayoutEffect, useRef } from "react";
+import { observeResize } from "~/lib/observeResize";
+import { buildDocJson } from "~/composer-rich-text-doc";
+import {
+ findAssistantCitationSourceAnchor,
+ type AssistantCitationSourceAnchor,
+} from "~/lib/assistantTextSelection";
+
+export function AssistantCitationMarkers({
+ viewport,
+ prompt,
+ onOpen,
+}: {
+ viewport: HTMLElement | null;
+ prompt: string;
+ onOpen: (index: number, anchor: AssistantCitationSourceAnchor) => void;
+}) {
+ const citations = draftCitationMarkers(buildDocJson(prompt, () => null));
+ const overlayRef = useRef(null);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions markers against rendered source ranges as the timeline scrolls and virtualizes rows
+ useLayoutEffect(() => {
+ const overlay = overlayRef.current;
+ if (!viewport || !overlay || citations.length === 0) return;
+ const update = () => {
+ const bounds = viewport.getBoundingClientRect();
+ for (const [index, { citation }] of citations.entries()) {
+ const button = overlay.children[index] as HTMLButtonElement;
+ const anchor = findAssistantCitationSourceAnchor(viewport.ownerDocument, citation);
+ const rect =
+ anchor && viewport.contains(anchor.source)
+ ? Array.from(anchor.range.getClientRects()).findLast(
+ (rect) => rect.width > 0 && rect.height > 0,
+ )
+ : null;
+ const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
+ button.hidden = !visible;
+ if (rect && visible) {
+ button.style.left = `${Math.min(bounds.width - 22, Math.max(0, rect.right - bounds.left + 3))}px`;
+ button.style.top = `${rect.bottom - bounds.top - 18}px`;
+ }
+ }
+ };
+ const stopResize = observeResize(viewport, update);
+ const mutations = new MutationObserver((records) => {
+ if (records.some((record) => !overlay.contains(record.target))) update();
+ });
+ mutations.observe(viewport, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ attributes: true,
+ attributeFilter: ["style"],
+ });
+ viewport.addEventListener("scroll", update, true);
+ window.addEventListener("resize", update);
+ update();
+ return () => {
+ stopResize();
+ mutations.disconnect();
+ viewport.removeEventListener("scroll", update, true);
+ window.removeEventListener("resize", update);
+ };
+ }, [viewport, citations]);
+ return (
+
+ {citations.map(({ citation, key }, index) => (
+
+ ))}
+
+ );
+}
+
+export function ActiveDraftCitationMarkers({
+ draftTarget,
+ ...props
+}: Omit[0], "prompt"> & {
+ draftTarget: ComposerThreadTarget;
+}) {
+ const prompt = useComposerDraftStore(
+ (store) => store.getComposerDraft(draftTarget)?.prompt ?? "",
+ );
+ return ;
+}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index be106a251592e..bcf08fd51ec31 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -302,6 +302,7 @@ export interface ChatComposerHandle {
options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer },
) => boolean;
pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean;
+ openCitation: (index: number, sourceAnchor: AssistantCitationSourceAnchor) => void;
citeAssistantText: (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
@@ -2966,6 +2967,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
focusComposer();
return true;
},
+ openCitation: (index, sourceAnchor) =>
+ composerEditorRef.current?.openCitation(index, sourceAnchor),
citeAssistantText: (citation, sourceAnchor) =>
insertComposerTextStable(
formatAssistantCitationForComposer(citation, citation.comment),
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 28d3299a7bd6d..addacdda2484e 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -1,3 +1,5 @@
+import type { ComposerThreadTarget } from "~/composerDraftStore";
+import { ActiveDraftCitationMarkers } from "./AssistantCitationMarkers";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import type { ToolCallDiffTarget } from "../../diffPanelStore";
@@ -221,6 +223,8 @@ interface MessagesTimelineProps {
onRespondToApproval?: TimelineRowSharedState["onRespondToApproval"];
citationRequest?: AssistantCitationRequest | null;
citationHistory?: CitedHistory;
+ citationDraftTarget?: ComposerThreadTarget;
+ onOpenDraftCitation?: (index: number, anchor: AssistantCitationSourceAnchor) => void;
onCiteAssistantText?: (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
@@ -338,6 +342,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
citationRequest = null,
citationHistory = CITED_HISTORY_LOADING,
onCiteAssistantText,
+ citationDraftTarget,
+ onOpenDraftCitation,
isWorking,
runlessWorkActive = false,
activeTurnStartedAt = null,
@@ -1319,6 +1325,13 @@ const ConversationTimeline = memo(function ConversationTimeline({
onCite={onCiteAssistantText}
/>
) : null}
+ {onOpenDraftCitation && citationDraftTarget && (
+
+ )}
ref={setTimelineList}
data={rows}
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
new file mode 100644
index 0000000000000..2ac44a8a5cef8
--- /dev/null
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
@@ -0,0 +1,29 @@
+import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
+import { expect, it } from "vite-plus/test";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
+import { buildDocJson } from "~/composer-rich-text-doc";
+import { draftCitationMarkers } from "./assistantCitationMarkers.logic";
+
+it("numbers only citation nodes in document order, including repeated sources", () => {
+ const citation = {
+ version: 1 as const,
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("message"),
+ text: "hello",
+ start: 0,
+ end: 5,
+ prefix: "",
+ suffix: "",
+ };
+ const second = { ...citation, text: "world", start: 6, end: 11 };
+ const link = serializeAssistantCitation(citation);
+ const doc = buildDocJson(`${serializeAssistantCitation(second)} ${link} ${link}`, () => null);
+ expect(draftCitationMarkers(doc).map((marker) => marker.citation)).toEqual([
+ second,
+ citation,
+ citation,
+ ]);
+ expect(draftCitationMarkers(buildDocJson("plain text", () => null))).toEqual([]);
+ expect(draftCitationMarkers(buildDocJson(`\`\`\`text\n${link}\n\`\`\``, () => null))).toEqual([]);
+});
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
new file mode 100644
index 0000000000000..9cac45c9eed88
--- /dev/null
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
@@ -0,0 +1,18 @@
+import type { AssistantCitation } from "@t3tools/contracts";
+import type { JSONContent } from "@tiptap/core";
+
+export function draftCitationMarkers(
+ doc: JSONContent,
+): { citation: AssistantCitation; key: string }[] {
+ const citations: { citation: AssistantCitation; key: string }[] = [];
+ const visit = (node: JSONContent) => {
+ if (node.type === "composer-citation")
+ citations.push({
+ citation: node.attrs?.citation as AssistantCitation,
+ key: node.attrs?.citeKey as string,
+ });
+ for (const child of node.content ?? []) visit(child);
+ };
+ visit(doc);
+ return citations;
+}
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 22b499ebe7b0c..200496ba8495b 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -140,6 +140,8 @@ delete it like other inline context. Copying, reloading, and restoring a
with its quote, and sending tells the agent which words were quoted and which comment you wrote.
The quoted text and comment count toward the message limit.
+Citations in the active draft show numbered markers beside their quoted passages in the conversation. Select a marker to reopen its comment at the source. Numbers follow the order of citations in the draft.
+
Mobile displays saved quotes and comments, but does not create citations or
navigate to their sources.
From fe306422ccba3a6f3e1c15773be4b14036867573 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 11:09:02 -0500
Subject: [PATCH 02/13] fix(web): stabilize draft citation markers and target
comments by identity
---
apps/web/src/components/ChatView.tsx | 4 +-
...ditorTiptap.citationDraft.browser.test.tsx | 36 ++++-
.../components/ComposerPromptEditorTiptap.tsx | 26 +++-
.../chat/AssistantCitationMarkers.tsx | 125 ++++++++++++++----
apps/web/src/components/chat/ChatComposer.tsx | 6 +-
.../src/components/chat/MessagesTimeline.tsx | 2 +-
.../assistantCitationMarkers.logic.test.ts | 16 +++
.../chat/assistantCitationMarkers.logic.ts | 28 ++--
apps/web/src/lib/assistantTextSelection.ts | 2 +-
9 files changed, 193 insertions(+), 52 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 4f73de39cf9db..666b9638d62e4 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -580,8 +580,8 @@ export default function ChatView(props: ChatViewProps) {
})() as AssistantCitationRequest | null;
const citationHistory = useCitedHistory(citationRequest);
const openDraftCitation = useStableCallback(
- (index: number, anchor: AssistantCitationSourceAnchor) =>
- composerRef.current?.openCitation(index, anchor),
+ (key: string, anchor: AssistantCitationSourceAnchor) =>
+ composerRef.current?.openCitation(key, anchor),
);
const { resolvedTheme } = useTheme();
const composerRuntimeMode = useComposerDraftStore(
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
index 918364d9570ed..0e167e17cecde 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
@@ -33,12 +33,14 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `;
function Composer({
question,
editorRef,
+ initialPrompt = promptBefore,
}: {
question: boolean;
+ initialPrompt?: string;
editorRef: React.RefObject;
}) {
const [viewport, setViewport] = useState(null);
- const [prompt, setPrompt] = useState(promptBefore);
+ const [prompt, setPrompt] = useState(initialPrompt);
const [answer, setAnswer] = useState("");
return (
<>
@@ -53,7 +55,7 @@ function Composer({
editorRef.current?.openCitation(index, anchor)}
+ onOpen={(key, anchor) => editorRef.current?.openCitation(key, anchor)}
/>
@@ -113,3 +115,33 @@ it("opens the existing comment at its source from a draft marker", async () => {
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore);
});
+
+it("opens each duplicate marker's own comment and restores marker focus", async () => {
+ const editorRef = createRef();
+ const first = withAssistantCitationComment(citation, "first");
+ const second = withAssistantCitationComment(citation, "second");
+ await using screen = await render(
+ ,
+ );
+ const one = screen.getByRole("button", { name: "Edit citation 1 comment" });
+ const two = screen.getByRole("button", { name: "Edit citation 2 comment" });
+ await expect.element(one).toBeVisible();
+ await expect.element(two).toBeVisible();
+ const firstButton = screen.container.querySelector("[data-citation-marker-key]")!;
+ const secondButton = screen.container.querySelectorAll(
+ "[data-citation-marker-key]",
+ )[1]!;
+ expect(firstButton.getBoundingClientRect().left).not.toBe(
+ secondButton.getBoundingClientRect().left,
+ );
+ await userEvent.click(one);
+ await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("first");
+ await userEvent.keyboard("{Escape}");
+ await expect.element(one).toHaveFocus();
+ await userEvent.click(two);
+ await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("second");
+});
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 765027e028f7c..6236ad8748305 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1,3 +1,4 @@
+import { draftCitationMarkers } from "./chat/assistantCitationMarkers.logic";
import { documentChipKindForFileName } from "~/pierre-icons";
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
@@ -135,7 +136,7 @@ export interface ComposerPromptEditorHandle {
focusAt: (cursor: number) => void;
focusAtEnd: () => void;
readSelectionRange: () => { start: number; end: number };
- openCitation: (index: number, sourceAnchor: AssistantCitationSourceAnchor) => void;
+ openCitation: (key: string, sourceAnchor: AssistantCitationSourceAnchor) => void;
requestCitationComment: (request: ComposerCitationCommentRequest) => void;
readSnapshot: () => {
value: string;
@@ -200,6 +201,7 @@ type CitationInsertedSpaces = { before: boolean; after: boolean };
type OpenCitationComment = {
key: string;
+ markerKey?: string;
sourceAnchor?: AssistantCitationSourceAnchor;
removeOnCancel?: CitationInsertedSpaces;
};
@@ -434,6 +436,15 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
};
const onRestoreFocus = () => {
+ if (commentTarget?.markerKey && commentTarget.sourceAnchor) {
+ const marker = commentTarget.sourceAnchor.viewport.querySelector(
+ `[data-citation-marker-key="${CSS.escape(commentTarget.markerKey)}"]`,
+ );
+ if (marker && !marker.hidden) {
+ marker.focus({ preventScroll: true });
+ return;
+ }
+ }
if (!editor.isEditable) return;
const pos = nodePos();
if (pos === null) return;
@@ -1640,13 +1651,18 @@ function ComposerPromptEditorTiptapInner(
readSnapshot();
return selectionRangeRef.current;
},
- openCitation: (index, sourceAnchor) => {
+ openCitation: (key, sourceAnchor) => {
if (!editor) return;
- const node = findChildren(
+ const nodes = findChildren(
editor.state.doc,
(node) => node.type.name === "composer-citation",
- )[index]?.node;
- if (node) setOpenCitation({ key: node.attrs.citeKey, sourceAnchor });
+ );
+ const markers = draftCitationMarkers({
+ type: "doc",
+ content: nodes.map(({ node }) => node.toJSON()),
+ });
+ const node = nodes[markers.findIndex((marker) => marker.key === key)]?.node;
+ if (node) setOpenCitation({ key: node.attrs.citeKey, markerKey: key, sourceAnchor });
},
requestCitationComment: (request) => {
citationRequestRef.current = request;
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index fd2e1761a8c3c..cd8cb9b369a45 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -15,52 +15,118 @@ export function AssistantCitationMarkers({
}: {
viewport: HTMLElement | null;
prompt: string;
- onOpen: (index: number, anchor: AssistantCitationSourceAnchor) => void;
+ onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void;
}) {
- const citations = draftCitationMarkers(buildDocJson(prompt, () => null));
+ const citationJson = JSON.stringify(draftCitationMarkers(buildDocJson(prompt, () => null)));
+ const citations = JSON.parse(citationJson) as ReturnType;
const overlayRef = useRef(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions markers against rendered source ranges as the timeline scrolls and virtualizes rows
useLayoutEffect(() => {
const overlay = overlayRef.current;
if (!viewport || !overlay || citations.length === 0) return;
+ let frame: number | null = null;
+ const anchors = new Map();
const update = () => {
+ frame = null;
const bounds = viewport.getBoundingClientRect();
- for (const [index, { citation }] of citations.entries()) {
+ const positions: { left: number; top: number }[] = [];
+ for (const [index, { citation, key }] of citations.entries()) {
const button = overlay.children[index] as HTMLButtonElement;
- const anchor = findAssistantCitationSourceAnchor(viewport.ownerDocument, citation);
+ if (!anchors.has(key)) {
+ const anchor = findAssistantCitationSourceAnchor(viewport, citation);
+ anchors.set(key, anchor);
+ if (anchor)
+ sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
+ }
+ const anchor = anchors.get(key);
const rect =
- anchor && viewport.contains(anchor.source)
- ? Array.from(anchor.range.getClientRects()).findLast(
- (rect) => rect.width > 0 && rect.height > 0,
- )
- : null;
- const visible = rect && rect.bottom > bounds.top && rect.top < bounds.bottom;
- button.hidden = !visible;
- if (rect && visible) {
- button.style.left = `${Math.min(bounds.width - 22, Math.max(0, rect.right - bounds.left + 3))}px`;
- button.style.top = `${rect.bottom - bounds.top - 18}px`;
+ anchor &&
+ Array.from(anchor.range.getClientRects()).findLast(
+ (rect) =>
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.bottom > bounds.top &&
+ rect.top < bounds.bottom &&
+ rect.right > bounds.left &&
+ rect.left < bounds.right,
+ );
+ button.hidden = !rect;
+ if (rect) {
+ let left = Math.min(
+ Math.max(0, bounds.width - 20),
+ Math.max(0, rect.right - bounds.left + 3),
+ );
+ let top = Math.min(
+ Math.max(0, bounds.height - 20),
+ Math.max(0, rect.bottom - bounds.top - 18),
+ );
+ const overlaps = (x: number, y: number) =>
+ positions.some(
+ (position) => Math.abs(position.left - x) < 22 && Math.abs(position.top - y) < 22,
+ );
+ if (overlaps(left, top)) {
+ let distance = Infinity;
+ let position: { left: number; top: number } | undefined;
+ for (let y = Math.max(0, bounds.height - 20); y >= 0; y -= 22) {
+ for (let x = Math.max(0, bounds.width - 20); x >= 0; x -= 22) {
+ const candidateDistance = Math.abs(x - left) + Math.abs(y - top);
+ if (!overlaps(x, y) && candidateDistance < distance) {
+ distance = candidateDistance;
+ position = { left: x, top: y };
+ }
+ }
+ }
+ if (position) {
+ left = position.left;
+ top = position.top;
+ }
+ }
+ positions.push({ left, top });
+ button.style.left = `${left}px`;
+ button.style.top = `${top}px`;
}
}
};
- const stopResize = observeResize(viewport, update);
+ const schedule = () => {
+ if (frame === null) frame = requestAnimationFrame(update);
+ };
+ const stopResize = observeResize(viewport, schedule);
const mutations = new MutationObserver((records) => {
- if (records.some((record) => !overlay.contains(record.target))) update();
+ const relevant = records.filter((record) => !overlay.contains(record.target));
+ if (relevant.length === 0) return;
+ for (const [key, anchor] of anchors) {
+ if (
+ !anchor ||
+ !viewport.contains(anchor.source) ||
+ relevant.some((record) => anchor.source.contains(record.target))
+ )
+ anchors.delete(key);
+ }
+ schedule();
});
- mutations.observe(viewport, {
- childList: true,
- subtree: true,
- characterData: true,
- attributes: true,
- attributeFilter: ["style"],
+ mutations.observe(viewport, { childList: true, subtree: true });
+ const sourceMutations = new MutationObserver((records) => {
+ for (const [key, anchor] of anchors) {
+ if (anchor && records.some((record) => anchor.source.contains(record.target)))
+ anchors.delete(key);
+ }
+ schedule();
});
- viewport.addEventListener("scroll", update, true);
- window.addEventListener("resize", update);
- update();
+ for (const { citation, key } of citations) {
+ const anchor = findAssistantCitationSourceAnchor(viewport, citation);
+ anchors.set(key, anchor);
+ if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
+ }
+ viewport.addEventListener("scroll", schedule, true);
+ window.addEventListener("resize", schedule);
+ schedule();
return () => {
+ if (frame !== null) cancelAnimationFrame(frame);
stopResize();
+ sourceMutations.disconnect();
mutations.disconnect();
- viewport.removeEventListener("scroll", update, true);
- window.removeEventListener("resize", update);
+ viewport.removeEventListener("scroll", schedule, true);
+ window.removeEventListener("resize", schedule);
};
}, [viewport, citations]);
return (
@@ -72,6 +138,7 @@ export function AssistantCitationMarkers({
{citations.map(({ citation, key }, index) => (
editorRef.current?.openCitation(key, anchor)}
/>
);
expect(drafts()?.answer).toBe("");
+ await expect
+ .element(page.getByRole("button", { name: "Edit citation 1 comment" }))
+ .not.toBeInTheDocument();
await screen.rerender();
expect(drafts()?.prompt).toBe(
@@ -145,3 +153,25 @@ it("opens each duplicate marker's own comment and restores marker focus", async
await userEvent.click(two);
await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("second");
});
+
+it("matches the editor citation nodes when rich text is toggled", async () => {
+ const editorRef = createRef();
+ const initialPrompt = "```text\n" + serializeAssistantCitation(citation) + "\n```";
+ await using screen = await render(
+ ,
+ );
+ const marker = page.getByRole("button", { name: "Edit citation 1 comment" });
+ await expect.element(marker).toBeVisible();
+ await marker.click();
+ await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
+ await userEvent.keyboard("{Escape}");
+ await screen.rerender(
+ ,
+ );
+ await expect.element(marker).not.toBeInTheDocument();
+});
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index cd8cb9b369a45..085204138cc4f 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -1,3 +1,4 @@
+import { Badge } from "~/components/ui/badge";
import { useComposerDraftStore, type ComposerThreadTarget } from "~/composerDraftStore";
import { draftCitationMarkers } from "./assistantCitationMarkers.logic";
import { useLayoutEffect, useRef } from "react";
@@ -11,13 +12,21 @@ import {
export function AssistantCitationMarkers({
viewport,
prompt,
+ richTextEnabled,
+ showingDraft = true,
onOpen,
}: {
viewport: HTMLElement | null;
prompt: string;
+ richTextEnabled: boolean;
+ showingDraft?: boolean;
onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void;
}) {
- const citationJson = JSON.stringify(draftCitationMarkers(buildDocJson(prompt, () => null)));
+ const citationJson = JSON.stringify(
+ draftCitationMarkers(
+ buildDocJson(showingDraft ? prompt : "", () => null, { styling: richTextEnabled }),
+ ),
+ );
const citations = JSON.parse(citationJson) as ReturnType;
const overlayRef = useRef(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions markers against rendered source ranges as the timeline scrolls and virtualizes rows
@@ -136,12 +145,12 @@ export function AssistantCitationMarkers({
aria-label="Draft citation markers"
>
{citations.map(({ citation, key }, index) => (
- }
hidden={!viewport}
- className="pointer-events-auto absolute flex size-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground"
+ className="pointer-events-auto absolute"
aria-label={`Edit citation ${index + 1} comment`}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
@@ -155,7 +164,7 @@ export function AssistantCitationMarkers({
}}
>
{index + 1}
-
+
))}
);
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 57910c8018a87..0c13258d29a86 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -224,6 +224,8 @@ interface MessagesTimelineProps {
citationRequest?: AssistantCitationRequest | null;
citationHistory?: CitedHistory;
citationDraftTarget?: ComposerThreadTarget;
+ citationRichTextEnabled?: boolean;
+ showingCitationDraft?: boolean;
onOpenDraftCitation?: (key: string, anchor: AssistantCitationSourceAnchor) => void;
onCiteAssistantText?: (
citation: AssistantCitation,
@@ -343,6 +345,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
citationHistory = CITED_HISTORY_LOADING,
onCiteAssistantText,
citationDraftTarget,
+ citationRichTextEnabled = false,
+ showingCitationDraft = true,
onOpenDraftCitation,
isWorking,
runlessWorkActive = false,
@@ -1329,6 +1333,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
)}
From b002e89c0fb572af4bdd333a9f4f607617852933 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 11:59:16 -0500
Subject: [PATCH 04/13] fix(web): keep citation marker updates bounded and
stable
---
.../AssistantCitationMarkers.browser.test.tsx | 60 ++++++++++++++++++
.../chat/AssistantCitationMarkers.tsx | 61 ++++++++++---------
2 files changed, 92 insertions(+), 29 deletions(-)
create mode 100644 apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
new file mode 100644
index 0000000000000..f1176e2fad2cb
--- /dev/null
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -0,0 +1,60 @@
+import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
+import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
+import { expect, it, vi } from "vite-plus/test";
+import { act, render } from "../../../test/render";
+import { AssistantCitationMarkers } from "./AssistantCitationMarkers";
+
+const mocks = vi.hoisted(() => ({ findAnchor: vi.fn() }));
+vi.mock(import("~/lib/assistantTextSelection"), () => ({
+ findAssistantCitationSourceAnchor: mocks.findAnchor,
+}));
+
+it("keeps source observations across typing and releases virtualized sources", async () => {
+ mocks.findAnchor.mockReset();
+ const viewport = document.createElement("div");
+ const source = document.createElement("p");
+ source.textContent = "hello";
+ viewport.append(source);
+ document.body.append(viewport);
+ using _fixture = { [Symbol.dispose]: () => viewport.remove() };
+ mocks.findAnchor.mockImplementation(() => {
+ if (!viewport.contains(source)) return null;
+ const range = document.createRange();
+ range.selectNodeContents(source);
+ return { source, range };
+ });
+ const prompt = serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("message"),
+ text: "hello",
+ start: 0,
+ end: 5,
+ prefix: "",
+ suffix: "",
+ });
+ const onOpen = vi.fn();
+ await using screen = await render(
+ ,
+ );
+ using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
+ const searches = mocks.findAnchor.mock.calls.length;
+ await screen.rerender(
+ ,
+ );
+ expect(disconnect).not.toHaveBeenCalled();
+ expect(mocks.findAnchor).toHaveBeenCalledTimes(searches);
+ await act(() => source.remove());
+ await vi.waitFor(() => expect(disconnect).toHaveBeenCalled());
+});
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 085204138cc4f..8d48e709a411d 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -27,8 +27,20 @@ export function AssistantCitationMarkers({
buildDocJson(showingDraft ? prompt : "", () => null, { styling: richTextEnabled }),
),
);
- const citations = JSON.parse(citationJson) as ReturnType;
+ return ;
+}
+
+function CitationMarkers({
+ viewport,
+ citationJson,
+ onOpen,
+}: {
+ viewport: HTMLElement | null;
+ citationJson: string;
+ onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void;
+}) {
const overlayRef = useRef(null);
+ const citations = JSON.parse(citationJson) as ReturnType;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions markers against rendered source ranges as the timeline scrolls and virtualizes rows
useLayoutEffect(() => {
const overlay = overlayRef.current;
@@ -38,8 +50,9 @@ export function AssistantCitationMarkers({
const update = () => {
frame = null;
const bounds = viewport.getBoundingClientRect();
- const positions: { left: number; top: number }[] = [];
- for (const [index, { citation, key }] of citations.entries()) {
+ const occurrences = new Map();
+ for (const [index, marker] of citations.entries()) {
+ const { citation, key } = marker;
const button = overlay.children[index] as HTMLButtonElement;
if (!anchors.has(key)) {
const anchor = findAssistantCitationSourceAnchor(viewport, citation);
@@ -61,36 +74,17 @@ export function AssistantCitationMarkers({
);
button.hidden = !rect;
if (rect) {
- let left = Math.min(
+ const rectKey = `${rect.right}:${rect.bottom}`;
+ const occurrence = occurrences.get(rectKey) ?? 0;
+ occurrences.set(rectKey, occurrence + 1);
+ const left = Math.min(
Math.max(0, bounds.width - 20),
Math.max(0, rect.right - bounds.left + 3),
);
- let top = Math.min(
+ const top = Math.min(
Math.max(0, bounds.height - 20),
- Math.max(0, rect.bottom - bounds.top - 18),
+ Math.max(0, rect.bottom - bounds.top - 18 - occurrence * 22),
);
- const overlaps = (x: number, y: number) =>
- positions.some(
- (position) => Math.abs(position.left - x) < 22 && Math.abs(position.top - y) < 22,
- );
- if (overlaps(left, top)) {
- let distance = Infinity;
- let position: { left: number; top: number } | undefined;
- for (let y = Math.max(0, bounds.height - 20); y >= 0; y -= 22) {
- for (let x = Math.max(0, bounds.width - 20); x >= 0; x -= 22) {
- const candidateDistance = Math.abs(x - left) + Math.abs(y - top);
- if (!overlaps(x, y) && candidateDistance < distance) {
- distance = candidateDistance;
- position = { left: x, top: y };
- }
- }
- }
- if (position) {
- left = position.left;
- top = position.top;
- }
- }
- positions.push({ left, top });
button.style.left = `${left}px`;
button.style.top = `${top}px`;
}
@@ -100,6 +94,12 @@ export function AssistantCitationMarkers({
if (frame === null) frame = requestAnimationFrame(update);
};
const stopResize = observeResize(viewport, schedule);
+ const rebuildSourceObservations = () => {
+ sourceMutations.disconnect();
+ for (const anchor of anchors.values()) {
+ if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
+ }
+ };
const mutations = new MutationObserver((records) => {
const relevant = records.filter((record) => !overlay.contains(record.target));
if (relevant.length === 0) return;
@@ -111,6 +111,7 @@ export function AssistantCitationMarkers({
)
anchors.delete(key);
}
+ rebuildSourceObservations();
schedule();
});
mutations.observe(viewport, { childList: true, subtree: true });
@@ -119,9 +120,11 @@ export function AssistantCitationMarkers({
if (anchor && records.some((record) => anchor.source.contains(record.target)))
anchors.delete(key);
}
+ rebuildSourceObservations();
schedule();
});
- for (const { citation, key } of citations) {
+ for (const marker of citations) {
+ const { citation, key } = marker;
const anchor = findAssistantCitationSourceAnchor(viewport, citation);
anchors.set(key, anchor);
if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
From e4a8754113162d90cc037a446b8913a7f1d03422 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 12:23:58 -0500
Subject: [PATCH 05/13] fix(web): spread duplicate citation markers
horizontally
---
.../chat/AssistantCitationMarkers.browser.test.tsx | 12 ++++++++++--
.../src/components/chat/AssistantCitationMarkers.tsx | 7 +++++--
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
index f1176e2fad2cb..60018359ffbd0 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -38,17 +38,25 @@ it("keeps source observations across typing and releases virtualized sources", a
await using screen = await render(
,
);
+ const markers = screen.container.querySelectorAll(
+ "[data-citation-marker-key]",
+ );
+ await vi.waitFor(() => {
+ expect(markers[0]!.style.left).not.toBe("");
+ expect(markers[0]!.style.left).not.toBe(markers[1]!.style.left);
+ expect(markers[0]!.style.top).toBe(markers[1]!.style.top);
+ });
using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
const searches = mocks.findAnchor.mock.calls.length;
await screen.rerender(
,
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 8d48e709a411d..c59af363f0edc 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -77,13 +77,16 @@ function CitationMarkers({
const rectKey = `${rect.right}:${rect.bottom}`;
const occurrence = occurrences.get(rectKey) ?? 0;
occurrences.set(rectKey, occurrence + 1);
- const left = Math.min(
+ const base = Math.min(
Math.max(0, bounds.width - 20),
Math.max(0, rect.right - bounds.left + 3),
);
+ const offset = occurrence * 22;
+ const left =
+ base + offset <= bounds.width - 20 ? base + offset : Math.max(0, base - offset);
const top = Math.min(
Math.max(0, bounds.height - 20),
- Math.max(0, rect.bottom - bounds.top - 18 - occurrence * 22),
+ Math.max(0, rect.bottom - bounds.top - 18),
);
button.style.left = `${left}px`;
button.style.top = `${top}px`;
From 2a40aa0b19fb5f7ec4d675921f5ba2ac5a6da376 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 13:34:47 -0500
Subject: [PATCH 06/13] fix(web): citation markers sit in the gutter instead of
covering text
Co-Authored-By: GPT-6.1 Sol via T3 Code
---
.../chat/AssistantCitationMarkers.browser.test.tsx | 10 ++++++++--
.../src/components/chat/AssistantCitationMarkers.tsx | 7 ++++---
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
index 60018359ffbd0..fd63f611e7ae6 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -13,14 +13,17 @@ it("keeps source observations across typing and releases virtualized sources", a
mocks.findAnchor.mockReset();
const viewport = document.createElement("div");
const source = document.createElement("p");
- source.textContent = "hello";
+ viewport.style.cssText = "position: fixed; left: 0; top: 0; width: 400px; height: 100px";
+ source.style.width = "200px";
+ source.textContent = "hello again";
viewport.append(source);
document.body.append(viewport);
using _fixture = { [Symbol.dispose]: () => viewport.remove() };
mocks.findAnchor.mockImplementation(() => {
if (!viewport.contains(source)) return null;
const range = document.createRange();
- range.selectNodeContents(source);
+ range.setStart(source.firstChild!, 0);
+ range.setEnd(source.firstChild!, 5);
return { source, range };
});
const prompt = serializeAssistantCitation({
@@ -50,6 +53,9 @@ it("keeps source observations across typing and releases virtualized sources", a
expect(markers[0]!.style.left).not.toBe("");
expect(markers[0]!.style.left).not.toBe(markers[1]!.style.left);
expect(markers[0]!.style.top).toBe(markers[1]!.style.top);
+ expect(
+ Number.parseFloat(markers[0]!.style.left) + viewport.getBoundingClientRect().left,
+ ).toBeGreaterThanOrEqual(source.getBoundingClientRect().right);
});
using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
const searches = mocks.findAnchor.mock.calls.length;
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index c59af363f0edc..992e2d0bd8cb1 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -73,13 +73,14 @@ function CitationMarkers({
rect.left < bounds.right,
);
button.hidden = !rect;
- if (rect) {
- const rectKey = `${rect.right}:${rect.bottom}`;
+ if (rect && anchor) {
+ const sourceRight = anchor.source.getBoundingClientRect().right;
+ const rectKey = `${sourceRight}:${rect.bottom}`;
const occurrence = occurrences.get(rectKey) ?? 0;
occurrences.set(rectKey, occurrence + 1);
const base = Math.min(
Math.max(0, bounds.width - 20),
- Math.max(0, rect.right - bounds.left + 3),
+ Math.max(0, sourceRight - bounds.left + 3),
);
const offset = occurrence * 22;
const left =
From 33b1537952add12f776562ebff58e4112e594cd9 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 14:41:20 -0500
Subject: [PATCH 07/13] feat(web): number composer citation chips to match
their markers
Co-Authored-By: GPT-6.1 Sol via T3 Code
---
.../components/ComposerPromptEditorTiptap.tsx | 18 ++++++++++++--
.../components/chat/AssistantCitationChip.tsx | 4 ++++
.../chat/AssistantCitationMarkers.tsx | 24 +++++++++++++++----
.../assistantCitationMarkers.logic.test.ts | 16 +++++++++++++
.../chat/assistantCitationMarkers.logic.ts | 18 +++++++++++---
docs/user/composer.md | 2 +-
6 files changed, 72 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 6236ad8748305..3e2c1ef06ee14 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1,3 +1,4 @@
+import { updateCitationHighlight } from "./chat/AssistantCitationMarkers";
import { draftCitationMarkers } from "./chat/assistantCitationMarkers.logic";
import { documentChipKindForFileName } from "~/pierre-icons";
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
@@ -49,7 +50,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type RefObject,
} from "react";
-import { EditorContent, useEditor } from "@tiptap/react";
+import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import {
clampCollapsedComposerCursor,
@@ -396,6 +397,13 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
const commentContext = use(ComposerCitationCommentContext);
const citation = node.attrs.citation as AssistantCitation;
const citeKey = node.attrs.citeKey as string;
+ const marker = useEditorState({
+ editor,
+ selector: ({ editor }) =>
+ draftCitationMarkers(editor.state.doc.toJSON(), true).find(
+ (marker) => marker.nodeKey === citeKey,
+ ),
+ });
const commentTarget =
commentContext.openComment?.key === citeKey ? commentContext.openComment : null;
@@ -456,9 +464,14 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
return (
) => {
if (
@@ -479,6 +492,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
>
+ {composer && citationNumber !== undefined ? {citationNumber} : null}
{composerSourceLink}
{commentEditor ? (
(
+ `[data-citation-link-key="${CSS.escape(key)}"]`,
+ );
+ const active = Array.from(matches).some((match) => match.matches(":hover, :focus-within"));
+ for (const match of matches) match.toggleAttribute("data-citation-highlight", active);
+}
+
export function AssistantCitationMarkers({
viewport,
prompt,
@@ -151,14 +162,19 @@ function CitationMarkers({
className="pointer-events-none absolute inset-0 z-10 overflow-hidden"
aria-label="Draft citation markers"
>
- {citations.map(({ citation, key }, index) => (
+ {citations.map(({ citation, key, number }) => (
}
hidden={!viewport}
- className="pointer-events-auto absolute"
- aria-label={`Edit citation ${index + 1} comment`}
+ className="pointer-events-auto absolute data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring"
+ aria-label={`Edit citation ${number} comment`}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
if (!viewport) return;
@@ -170,7 +186,7 @@ function CitationMarkers({
onOpen(key, anchor);
}}
>
- {index + 1}
+ {number}
))}
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
index 9739e40232907..29bb4ff715a77 100644
--- a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
@@ -1,3 +1,4 @@
+import type { JSONContent } from "@tiptap/core";
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { expect, it } from "vite-plus/test";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
@@ -25,6 +26,21 @@ it("numbers only citation nodes in document order, including repeated sources",
citation,
]);
const markers = draftCitationMarkers(doc);
+ const composerDoc = structuredClone(doc);
+ let nodeIndex = 0;
+ const assignKeys = (node: JSONContent) => {
+ if (node.type === "composer-citation")
+ node.attrs = { ...node.attrs, citeKey: `chip-${nodeIndex++}` };
+ for (const child of node.content ?? []) assignKeys(child);
+ };
+ assignKeys(composerDoc);
+ const chips = draftCitationMarkers(composerDoc, true);
+ for (const index of [0, 1]) {
+ const chip = chips.find((marker) => marker.nodeKey === `chip-${index}`);
+ expect(chip?.number).toBe(markers[index]?.number);
+ expect(chip?.number).toBe(index + 1);
+ expect(chip?.key).toBe(markers[index]?.key);
+ }
expect(new Set(markers.map(({ key }) => key)).size).toBe(3);
expect(
draftCitationMarkers(
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
index cf06c6bef1a87..c4b4db0b7bce7 100644
--- a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
@@ -4,8 +4,13 @@ import * as Schema from "effect/Schema";
const isCitation = Schema.is(AssistantCitation);
-export function draftCitationMarkers(doc: JSONContent) {
- const citations: { citation: AssistantCitation; key: string }[] = [];
+export function draftCitationMarkers(doc: JSONContent, includeNodeKeys = false) {
+ const citations: {
+ citation: AssistantCitation;
+ key: string;
+ number: number;
+ nodeKey?: string;
+ }[] = [];
const occurrences = new Map();
const visit = (node: JSONContent) => {
const citation: unknown = node.attrs?.citation;
@@ -19,7 +24,14 @@ export function draftCitationMarkers(doc: JSONContent) {
]);
const occurrence = occurrences.get(identity) ?? 0;
occurrences.set(identity, occurrence + 1);
- citations.push({ citation, key: `${identity}:${occurrence}` });
+ citations.push({
+ citation,
+ key: `${identity}:${occurrence}`,
+ number: citations.length + 1,
+ ...(includeNodeKeys && typeof node.attrs?.citeKey === "string"
+ ? { nodeKey: node.attrs.citeKey }
+ : {}),
+ });
}
for (const child of node.content ?? []) visit(child);
};
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 200496ba8495b..295c9071d8013 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -140,7 +140,7 @@ delete it like other inline context. Copying, reloading, and restoring a
with its quote, and sending tells the agent which words were quoted and which comment you wrote.
The quoted text and comment count toward the message limit.
-Citations in the active draft show numbered markers beside their quoted passages in the conversation. Select a marker to reopen its comment at the source. Numbers follow the order of citations in the draft.
+Citations in the active draft show numbered markers beside their quoted passages in the conversation. Select a marker to reopen its comment at the source. Numbers follow the order of citations in the draft and match the badges on composer chips.
Mobile displays saved quotes and comments, but does not create citations or
navigate to their sources.
From c03912f3d38d9992ee1120fcfcaddcd49f11c126 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 14:57:09 -0500
Subject: [PATCH 08/13] fix(web): cheaper citation chip numbering and review
fixes
Co-Authored-By: GPT-6.1 Sol via T3 Code
---
...ditorTiptap.citationDraft.browser.test.tsx | 15 +++--
.../components/ComposerPromptEditorTiptap.tsx | 19 ++++---
.../chat/AssistantCitationMarkers.tsx | 6 +-
.../assistantCitationMarkers.logic.test.ts | 27 ++++++---
.../chat/assistantCitationMarkers.logic.ts | 55 ++++++++++++++-----
5 files changed, 81 insertions(+), 41 deletions(-)
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
index 4f25e701667d1..bcf7e80071366 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
@@ -35,10 +35,12 @@ function Composer({
editorRef,
initialPrompt = promptBefore,
richTextEnabled = false,
+ disabled = false,
}: {
question: boolean;
initialPrompt?: string;
richTextEnabled?: boolean;
+ disabled?: boolean;
editorRef: React.RefObject;
}) {
const [viewport, setViewport] = useState(null);
@@ -70,7 +72,7 @@ function Composer({
cursor={0}
contextRecords={new Map()}
skills={[]}
- disabled={false}
+ disabled={disabled}
placeholder=""
onChange={(next) => (question ? setAnswer(next) : setPrompt(next))}
onCitationDraftSave={(replacedValue, savedValue) => {
@@ -122,6 +124,10 @@ it("opens the existing comment at its source from a draft marker", async () => {
await userEvent.click(screen.getByRole("button", { name: "Edit citation 1 comment" }));
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore);
+ await userEvent.keyboard("{Escape}");
+ await screen.rerender();
+ await userEvent.click(screen.getByRole("button", { name: "Edit citation 1 comment" }));
+ await expect.element(page.getByLabelText("Comment on selected text")).not.toBeInTheDocument();
});
it("opens each duplicate marker's own comment and restores marker focus", async () => {
@@ -139,13 +145,6 @@ it("opens each duplicate marker's own comment and restores marker focus", async
const two = screen.getByRole("button", { name: "Edit citation 2 comment" });
await expect.element(one).toBeVisible();
await expect.element(two).toBeVisible();
- const firstButton = screen.container.querySelector("[data-citation-marker-key]")!;
- const secondButton = screen.container.querySelectorAll(
- "[data-citation-marker-key]",
- )[1]!;
- expect(firstButton.getBoundingClientRect().left).not.toBe(
- secondButton.getBoundingClientRect().left,
- );
await userEvent.click(one);
await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("first");
await userEvent.keyboard("{Escape}");
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 3e2c1ef06ee14..f6ce33b11b7c9 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1,5 +1,8 @@
import { updateCitationHighlight } from "./chat/AssistantCitationMarkers";
-import { draftCitationMarkers } from "./chat/assistantCitationMarkers.logic";
+import {
+ composerCitationMarker,
+ draftCitationMarkers,
+} from "./chat/assistantCitationMarkers.logic";
import { documentChipKindForFileName } from "~/pierre-icons";
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
@@ -399,11 +402,11 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
const citeKey = node.attrs.citeKey as string;
const marker = useEditorState({
editor,
- selector: ({ editor }) =>
- draftCitationMarkers(editor.state.doc.toJSON(), true).find(
- (marker) => marker.nodeKey === citeKey,
- ),
+ selector: ({ editor }) => composerCitationMarker(editor.state.doc, citeKey),
});
+ const separator = marker?.indexOf("|") ?? -1;
+ const markerNumber = marker ? Number(marker.slice(0, separator)) : undefined;
+ const markerKey = marker?.slice(separator + 1);
const commentTarget =
commentContext.openComment?.key === citeKey ? commentContext.openComment : null;
@@ -467,7 +470,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
className={`${CHIP_NODE_SELECTION_CLASS_NAME} data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring`}
contentEditable={false}
spellCheck={false}
- data-citation-link-key={marker?.key}
+ data-citation-link-key={markerKey}
onPointerEnter={updateCitationHighlight}
onPointerLeave={updateCitationHighlight}
onFocus={updateCitationHighlight}
@@ -492,7 +495,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
>
{
- if (!editor) return;
+ if (!editor?.isEditable) return;
const nodes = findChildren(
editor.state.doc,
(node) => node.type.name === "composer-citation",
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 41c0c2ee38f71..a336b02b78568 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -34,9 +34,9 @@ export function AssistantCitationMarkers({
onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void;
}) {
const citationJson = JSON.stringify(
- draftCitationMarkers(
- buildDocJson(showingDraft ? prompt : "", () => null, { styling: richTextEnabled }),
- ),
+ showingDraft && prompt.includes("[Assistant quote](t3-citation://v1/")
+ ? draftCitationMarkers(buildDocJson(prompt, () => null, { styling: richTextEnabled }))
+ : [],
);
return ;
}
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
index 29bb4ff715a77..168d5a29c781b 100644
--- a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts
@@ -1,9 +1,10 @@
import type { JSONContent } from "@tiptap/core";
+import { Schema } from "@tiptap/pm/model";
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { expect, it } from "vite-plus/test";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import { buildDocJson } from "~/composer-rich-text-doc";
-import { draftCitationMarkers } from "./assistantCitationMarkers.logic";
+import { composerCitationMarker, draftCitationMarkers } from "./assistantCitationMarkers.logic";
it("numbers only citation nodes in document order, including repeated sources", () => {
const citation = {
@@ -34,13 +35,25 @@ it("numbers only citation nodes in document order, including repeated sources",
for (const child of node.content ?? []) assignKeys(child);
};
assignKeys(composerDoc);
- const chips = draftCitationMarkers(composerDoc, true);
- for (const index of [0, 1]) {
- const chip = chips.find((marker) => marker.nodeKey === `chip-${index}`);
- expect(chip?.number).toBe(markers[index]?.number);
- expect(chip?.number).toBe(index + 1);
- expect(chip?.key).toBe(markers[index]?.key);
+ const schema = new Schema({
+ nodes: {
+ doc: { content: "paragraph+" },
+ paragraph: { content: "inline*" },
+ text: { group: "inline" },
+ "composer-citation": {
+ inline: true,
+ group: "inline",
+ attrs: { citation: {}, citeKey: {}, source: { default: "" } },
+ },
+ },
+ });
+ const pmDoc = schema.nodeFromJSON(composerDoc);
+ for (const index of [0, 1, 2]) {
+ expect(composerCitationMarker(pmDoc, `chip-${index}`)).toBe(
+ `${markers[index]?.number}|${markers[index]?.key}`,
+ );
}
+ expect(composerCitationMarker(pmDoc, "missing")).toBeUndefined();
expect(new Set(markers.map(({ key }) => key)).size).toBe(3);
expect(
draftCitationMarkers(
diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
index c4b4db0b7bce7..f4f3b88c430e9 100644
--- a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
+++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts
@@ -1,36 +1,25 @@
import { AssistantCitation } from "@t3tools/contracts";
import type { JSONContent } from "@tiptap/core";
+import type { Node } from "@tiptap/pm/model";
import * as Schema from "effect/Schema";
const isCitation = Schema.is(AssistantCitation);
-export function draftCitationMarkers(doc: JSONContent, includeNodeKeys = false) {
+export function draftCitationMarkers(doc: JSONContent) {
const citations: {
citation: AssistantCitation;
key: string;
number: number;
- nodeKey?: string;
}[] = [];
const occurrences = new Map();
const visit = (node: JSONContent) => {
const citation: unknown = node.attrs?.citation;
if (node.type === "composer-citation" && isCitation(citation)) {
- const identity = JSON.stringify([
- citation.environmentId,
- citation.threadId,
- citation.messageId,
- citation.start,
- citation.end,
- ]);
- const occurrence = occurrences.get(identity) ?? 0;
- occurrences.set(identity, occurrence + 1);
+ const key = nextCitationMarkerKey(citation, occurrences);
citations.push({
citation,
- key: `${identity}:${occurrence}`,
+ key,
number: citations.length + 1,
- ...(includeNodeKeys && typeof node.attrs?.citeKey === "string"
- ? { nodeKey: node.attrs.citeKey }
- : {}),
});
}
for (const child of node.content ?? []) visit(child);
@@ -38,3 +27,39 @@ export function draftCitationMarkers(doc: JSONContent, includeNodeKeys = false)
visit(doc);
return citations;
}
+
+function nextCitationMarkerKey(citation: AssistantCitation, occurrences: Map) {
+ const identity = JSON.stringify([
+ citation.environmentId,
+ citation.threadId,
+ citation.messageId,
+ citation.start,
+ citation.end,
+ ]);
+ const occurrence = occurrences.get(identity) ?? 0;
+ occurrences.set(identity, occurrence + 1);
+ return `${identity}:${occurrence}`;
+}
+
+export function composerCitationMarker(doc: Node, citeKey: string) {
+ const occurrences = new Map();
+ let number = 0;
+ let marker: string | undefined;
+ const visit = (node: Node): boolean => {
+ const citation: unknown = node.attrs.citation;
+ if (node.type.name === "composer-citation" && isCitation(citation)) {
+ const key = nextCitationMarkerKey(citation, occurrences);
+ number++;
+ if (node.attrs.citeKey === citeKey) {
+ marker = `${number}|${key}`;
+ return true;
+ }
+ }
+ for (let index = 0; index < node.childCount; index++) {
+ if (visit(node.child(index))) return true;
+ }
+ return false;
+ };
+ visit(doc);
+ return marker;
+}
From 051107b3aeae2e12b5bf5eb8fd528c11a06c5fb4 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 15:17:11 -0500
Subject: [PATCH 09/13] fix(web): citation markers stay off text and follow
moving rows
Co-Authored-By: GPT-6.1 Sol via T3 Code
---
.../AssistantCitationMarkers.browser.test.tsx | 171 +++++++++++-------
.../chat/AssistantCitationMarkers.tsx | 57 ++++--
apps/web/src/index.css | 6 +-
3 files changed, 154 insertions(+), 80 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
index fd63f611e7ae6..9d816f3532936 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -4,71 +4,116 @@ import { expect, it, vi } from "vite-plus/test";
import { act, render } from "../../../test/render";
import { AssistantCitationMarkers } from "./AssistantCitationMarkers";
+import "~/index.css";
+
const mocks = vi.hoisted(() => ({ findAnchor: vi.fn() }));
vi.mock(import("~/lib/assistantTextSelection"), () => ({
findAssistantCitationSourceAnchor: mocks.findAnchor,
}));
-it("keeps source observations across typing and releases virtualized sources", async () => {
- mocks.findAnchor.mockReset();
- const viewport = document.createElement("div");
- const source = document.createElement("p");
- viewport.style.cssText = "position: fixed; left: 0; top: 0; width: 400px; height: 100px";
- source.style.width = "200px";
- source.textContent = "hello again";
- viewport.append(source);
- document.body.append(viewport);
- using _fixture = { [Symbol.dispose]: () => viewport.remove() };
- mocks.findAnchor.mockImplementation(() => {
- if (!viewport.contains(source)) return null;
- const range = document.createRange();
- range.setStart(source.firstChild!, 0);
- range.setEnd(source.firstChild!, 5);
- return { source, range };
- });
- const prompt = serializeAssistantCitation({
- version: 1,
- environmentId: EnvironmentId.make("env"),
- threadId: ThreadId.make("thread"),
- messageId: MessageId.make("message"),
- text: "hello",
- start: 0,
- end: 5,
- prefix: "",
- suffix: "",
- });
- const onOpen = vi.fn();
- await using screen = await render(
- ,
- );
- const markers = screen.container.querySelectorAll(
- "[data-citation-marker-key]",
- );
- await vi.waitFor(() => {
- expect(markers[0]!.style.left).not.toBe("");
- expect(markers[0]!.style.left).not.toBe(markers[1]!.style.left);
- expect(markers[0]!.style.top).toBe(markers[1]!.style.top);
- expect(
- Number.parseFloat(markers[0]!.style.left) + viewport.getBoundingClientRect().left,
- ).toBeGreaterThanOrEqual(source.getBoundingClientRect().right);
- });
- using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
- const searches = mocks.findAnchor.mock.calls.length;
- await screen.rerender(
- ,
- );
- expect(disconnect).not.toHaveBeenCalled();
- expect(mocks.findAnchor).toHaveBeenCalledTimes(searches);
- await act(() => source.remove());
- await vi.waitFor(() => expect(disconnect).toHaveBeenCalled());
-});
+it.each(["moving sibling", "queued text", "narrow gutter"])(
+ "keeps source observations across typing and %s changes",
+ async (scenario) => {
+ mocks.findAnchor.mockReset();
+ const viewport = document.createElement("div");
+ const source = document.createElement("p");
+ viewport.style.cssText = "position: fixed; left: 0; top: 0; width: 400px; height: 100px";
+ source.style.width = "200px";
+ source.textContent = "hello again";
+ const sibling = document.createElement("div");
+ const content = document.createElement("div");
+ content.className = "messages-timeline-scroll";
+ content.append(sibling, source);
+ viewport.append(content);
+ const canvas = document.createElement("div");
+ canvas.setAttribute("data-chat-canvas", "");
+ canvas.append(viewport);
+ document.body.append(canvas);
+ using _fixture = { [Symbol.dispose]: () => canvas.remove() };
+ mocks.findAnchor.mockImplementation(() => {
+ if (!viewport.contains(source)) return null;
+ const range = document.createRange();
+ range.setStart(source.firstChild!, 0);
+ range.setEnd(source.firstChild!, 5);
+ return { source, range };
+ });
+ const prompt = serializeAssistantCitation({
+ version: 1,
+ environmentId: EnvironmentId.make("env"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("message"),
+ text: "hello",
+ start: 0,
+ end: 5,
+ prefix: "",
+ suffix: "",
+ });
+ const onOpen = vi.fn();
+ await using screen = await render(
+ ,
+ );
+ const markers = screen.container.querySelectorAll(
+ "[data-citation-marker-key]",
+ );
+ await vi.waitFor(() => {
+ expect(markers[0]!.style.left).not.toBe("");
+ expect(markers[0]!.style.left).not.toBe(markers[1]!.style.left);
+ expect(markers[0]!.style.top).toBe(markers[1]!.style.top);
+ expect(
+ Number.parseFloat(markers[0]!.style.left) + viewport.getBoundingClientRect().left,
+ ).toBeGreaterThanOrEqual(source.getBoundingClientRect().right);
+ });
+ using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
+ const searches = mocks.findAnchor.mock.calls.length;
+ await screen.rerender(
+ ,
+ );
+ expect(disconnect).not.toHaveBeenCalled();
+ expect(mocks.findAnchor).toHaveBeenCalledTimes(searches);
+ if (scenario === "moving sibling") {
+ const previousTop = markers[0]!.style.top;
+ await act(() => {
+ sibling.style.height = "30px";
+ });
+ await vi.waitFor(() => expect(markers[0]!.style.top).not.toBe(previousTop));
+ }
+ if (scenario === "queued text") {
+ await act(() => {
+ viewport.append(document.createElement("div"));
+ source.firstChild!.textContent = "hello updated";
+ });
+ await vi.waitFor(() => {
+ expect(mocks.findAnchor.mock.calls.length).toBeGreaterThan(searches);
+ expect(markers[0]!.hidden).toBe(false);
+ });
+ }
+ if (scenario === "narrow gutter") {
+ await act(() => {
+ source.style.width = "100%";
+ viewport.dispatchEvent(new Event("scroll"));
+ });
+ await vi.waitFor(() => {
+ for (const marker of markers) {
+ expect(Number.parseFloat(marker.style.left)).toBeGreaterThanOrEqual(
+ source.getBoundingClientRect().right - viewport.getBoundingClientRect().left,
+ );
+ expect(Number.parseFloat(marker.style.left) + marker.offsetWidth).toBeLessThanOrEqual(
+ viewport.clientWidth,
+ );
+ }
+ });
+ }
+ await act(() => source.remove());
+ await vi.waitFor(() => expect(disconnect).toHaveBeenCalled());
+ },
+);
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index a336b02b78568..82294d596ff64 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -57,17 +57,20 @@ function CitationMarkers({
const overlay = overlayRef.current;
if (!viewport || !overlay || citations.length === 0) return;
let frame: number | null = null;
+ viewport.setAttribute("data-has-draft-citations", "");
const anchors = new Map();
const update = () => {
frame = null;
const bounds = viewport.getBoundingClientRect();
const occurrences = new Map();
+ let resolvedSource = false;
for (const [index, marker] of citations.entries()) {
const { citation, key } = marker;
const button = overlay.children[index] as HTMLButtonElement;
if (!anchors.has(key)) {
const anchor = findAssistantCitationSourceAnchor(viewport, citation);
anchors.set(key, anchor);
+ resolvedSource = true;
if (anchor)
sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
}
@@ -89,28 +92,45 @@ function CitationMarkers({
const rectKey = `${sourceRight}:${rect.bottom}`;
const occurrence = occurrences.get(rectKey) ?? 0;
occurrences.set(rectKey, occurrence + 1);
- const base = Math.min(
- Math.max(0, bounds.width - 20),
- Math.max(0, sourceRight - bounds.left + 3),
- );
- const offset = occurrence * 22;
- const left =
- base + offset <= bounds.width - 20 ? base + offset : Math.max(0, base - offset);
- const top = Math.min(
- Math.max(0, bounds.height - 20),
- Math.max(0, rect.bottom - bounds.top - 18),
- );
+ const width = button.offsetWidth;
+ const height = button.offsetHeight;
+ const base = Math.max(0, sourceRight - bounds.left + 3);
+ const columns = Math.max(1, Math.floor((bounds.width - base + 2) / (width + 2)));
+ const left = base + (occurrence % columns) * (width + 2);
+ const top =
+ Math.max(0, rect.bottom - bounds.top - height) +
+ Math.floor(occurrence / columns) * (height + 2);
button.style.left = `${left}px`;
button.style.top = `${top}px`;
}
}
+ if (resolvedSource) rebuildSourceObservations();
};
const schedule = () => {
if (frame === null) frame = requestAnimationFrame(update);
};
const stopResize = observeResize(viewport, schedule);
+ let stopSourceResize = () => {};
+ const invalidateText = (records: MutationRecord[]) => {
+ for (const [key, anchor] of anchors) {
+ if (anchor && records.some((record) => anchor.source.contains(record.target)))
+ anchors.delete(key);
+ }
+ };
const rebuildSourceObservations = () => {
+ invalidateText(sourceMutations.takeRecords());
sourceMutations.disconnect();
+ stopSourceResize();
+ const geometry = new Set();
+ for (const anchor of anchors.values()) {
+ for (
+ let node: Element | null = anchor?.source ?? null;
+ node && node !== viewport;
+ node = node.parentElement
+ )
+ geometry.add(node);
+ }
+ stopSourceResize = observeResize([...geometry], schedule);
for (const anchor of anchors.values()) {
if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
}
@@ -129,12 +149,14 @@ function CitationMarkers({
rebuildSourceObservations();
schedule();
});
- mutations.observe(viewport, { childList: true, subtree: true });
+ mutations.observe(viewport, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["style", "class"],
+ });
const sourceMutations = new MutationObserver((records) => {
- for (const [key, anchor] of anchors) {
- if (anchor && records.some((record) => anchor.source.contains(record.target)))
- anchors.delete(key);
- }
+ invalidateText(records);
rebuildSourceObservations();
schedule();
});
@@ -144,11 +166,14 @@ function CitationMarkers({
anchors.set(key, anchor);
if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
}
+ rebuildSourceObservations();
viewport.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
schedule();
return () => {
if (frame !== null) cancelAnimationFrame(frame);
+ viewport.removeAttribute("data-has-draft-citations");
+ stopSourceResize();
stopResize();
sourceMutations.disconnect();
mutations.disconnect();
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index e2942a5ece525..cbf6402106d93 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -2542,9 +2542,13 @@ code {
0.75rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px)
);
padding-inline-end: calc(
- 0.75rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px)
+ max(0.75rem, var(--draft-citation-gutter, 0px)) + var(--chat-lane-inset-end, 0px) -
+ var(--chat-timeline-gutter, 0px)
);
}
+ [data-has-draft-citations] {
+ --draft-citation-gutter: 2rem;
+ }
.chat-scroll-to-bottom,
.chat-banner-lane {
inset-inline-start: var(--chat-lane-inset-start, 0px);
From 45fd66404c6d93cd7ac3e3a7aba516e4d7625621 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 15:27:20 -0500
Subject: [PATCH 10/13] fix(web): make the linked citation marker highlight
visible
Co-Authored-By: Claude Opus 5.5
---
apps/web/src/components/chat/AssistantCitationMarkers.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 82294d596ff64..6444a88d64f75 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -198,7 +198,7 @@ function CitationMarkers({
onBlur={updateCitationHighlight}
render={}
hidden={!viewport}
- className="pointer-events-auto absolute data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring"
+ className="pointer-events-auto absolute data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring data-[citation-highlight]:ring-offset-2 data-[citation-highlight]:ring-offset-background"
aria-label={`Edit citation ${number} comment`}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
From f3e7d04b01c190ea0ff5fa083020e9503f8cf563 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Sat, 7 Nov 2026 15:55:38 -0500
Subject: [PATCH 11/13] refactor(web): style the marker highlight in feature
CSS and share the citation prefix
Co-Authored-By: Claude Opus 5.5
---
apps/web/src/components/chat/AssistantCitationMarkers.tsx | 5 +++--
apps/web/src/index.css | 5 +++++
packages/shared/src/assistantCitations.ts | 1 +
3 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 6444a88d64f75..bbc27ffa50910 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -1,3 +1,4 @@
+import { ASSISTANT_CITATION_MARKDOWN_PREFIX } from "@t3tools/shared/assistantCitations";
import { Badge } from "~/components/ui/badge";
import { useComposerDraftStore, type ComposerThreadTarget } from "~/composerDraftStore";
import { draftCitationMarkers } from "./assistantCitationMarkers.logic";
@@ -34,7 +35,7 @@ export function AssistantCitationMarkers({
onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void;
}) {
const citationJson = JSON.stringify(
- showingDraft && prompt.includes("[Assistant quote](t3-citation://v1/")
+ showingDraft && prompt.includes(ASSISTANT_CITATION_MARKDOWN_PREFIX)
? draftCitationMarkers(buildDocJson(prompt, () => null, { styling: richTextEnabled }))
: [],
);
@@ -198,7 +199,7 @@ function CitationMarkers({
onBlur={updateCitationHighlight}
render={}
hidden={!viewport}
- className="pointer-events-auto absolute data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring data-[citation-highlight]:ring-offset-2 data-[citation-highlight]:ring-offset-background"
+ className="pointer-events-auto absolute"
aria-label={`Edit citation ${number} comment`}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index cbf6402106d93..66062d87d9e10 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -2549,6 +2549,11 @@ code {
[data-has-draft-citations] {
--draft-citation-gutter: 2rem;
}
+ [data-citation-marker-key][data-citation-highlight] {
+ box-shadow:
+ 0 0 0 2px var(--background),
+ 0 0 0 4px var(--ring);
+ }
.chat-scroll-to-bottom,
.chat-banner-lane {
inset-inline-start: var(--chat-lane-inset-start, 0px);
diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts
index 64bb2b3904ba1..f120204e51452 100644
--- a/packages/shared/src/assistantCitations.ts
+++ b/packages/shared/src/assistantCitations.ts
@@ -8,6 +8,7 @@ import * as Schema from "effect/Schema";
const CITATION_PROTOCOL = "t3-citation:";
const CITATION_HREF_PREFIX = `${CITATION_PROTOCOL}//v1/`;
+export const ASSISTANT_CITATION_MARKDOWN_PREFIX = `[Assistant quote](${CITATION_HREF_PREFIX}`;
// Percent encoding needs up to nine characters per UTF-16 code unit; 16k covers selectors.
const MAX_CITATION_HREF_LENGTH =
9 * (ASSISTANT_CITATION_MAX_TEXT_LENGTH + ASSISTANT_CITATION_MAX_COMMENT_LENGTH) + 16_000;
From 52c824ff1c3dde0dfb8d152f3a96085fd8571df0 Mon Sep 17 00:00:00 2001
From: Elliot Drel
Date: Mon, 9 Nov 2026 09:14:23 -0500
Subject: [PATCH 12/13] refactor(web): consolidate citation marker observers
Observe source text and timeline structure through one mutation observer, removing queue coordination while preserving selective anchor invalidation and event-driven positioning.
Co-Authored-By: GPT-6.1 Sol
---
.../AssistantCitationMarkers.browser.test.tsx | 6 +--
.../chat/AssistantCitationMarkers.tsx | 37 +++----------------
2 files changed, 8 insertions(+), 35 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
index 9d816f3532936..e4b4011b02c4a 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -68,7 +68,6 @@ it.each(["moving sibling", "queued text", "narrow gutter"])(
Number.parseFloat(markers[0]!.style.left) + viewport.getBoundingClientRect().left,
).toBeGreaterThanOrEqual(source.getBoundingClientRect().right);
});
- using disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
const searches = mocks.findAnchor.mock.calls.length;
await screen.rerender(
,
);
- expect(disconnect).not.toHaveBeenCalled();
expect(mocks.findAnchor).toHaveBeenCalledTimes(searches);
if (scenario === "moving sibling") {
const previousTop = markers[0]!.style.top;
@@ -114,6 +112,8 @@ it.each(["moving sibling", "queued text", "narrow gutter"])(
});
}
await act(() => source.remove());
- await vi.waitFor(() => expect(disconnect).toHaveBeenCalled());
+ await vi.waitFor(() => {
+ for (const marker of markers) expect(marker.hidden).toBe(true);
+ });
},
);
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index bbc27ffa50910..9aab070a26704 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -72,8 +72,6 @@ function CitationMarkers({
const anchor = findAssistantCitationSourceAnchor(viewport, citation);
anchors.set(key, anchor);
resolvedSource = true;
- if (anchor)
- sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
}
const anchor = anchors.get(key);
const rect =
@@ -110,19 +108,10 @@ function CitationMarkers({
const schedule = () => {
if (frame === null) frame = requestAnimationFrame(update);
};
- const stopResize = observeResize(viewport, schedule);
- let stopSourceResize = () => {};
- const invalidateText = (records: MutationRecord[]) => {
- for (const [key, anchor] of anchors) {
- if (anchor && records.some((record) => anchor.source.contains(record.target)))
- anchors.delete(key);
- }
- };
+ let stopResize = () => {};
const rebuildSourceObservations = () => {
- invalidateText(sourceMutations.takeRecords());
- sourceMutations.disconnect();
- stopSourceResize();
- const geometry = new Set();
+ stopResize();
+ const geometry = new Set([viewport]);
for (const anchor of anchors.values()) {
for (
let node: Element | null = anchor?.source ?? null;
@@ -131,10 +120,7 @@ function CitationMarkers({
)
geometry.add(node);
}
- stopSourceResize = observeResize([...geometry], schedule);
- for (const anchor of anchors.values()) {
- if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
- }
+ stopResize = observeResize([...geometry], schedule);
};
const mutations = new MutationObserver((records) => {
const relevant = records.filter((record) => !overlay.contains(record.target));
@@ -153,30 +139,17 @@ function CitationMarkers({
mutations.observe(viewport, {
childList: true,
subtree: true,
+ characterData: true,
attributes: true,
attributeFilter: ["style", "class"],
});
- const sourceMutations = new MutationObserver((records) => {
- invalidateText(records);
- rebuildSourceObservations();
- schedule();
- });
- for (const marker of citations) {
- const { citation, key } = marker;
- const anchor = findAssistantCitationSourceAnchor(viewport, citation);
- anchors.set(key, anchor);
- if (anchor) sourceMutations.observe(anchor.source, { subtree: true, characterData: true });
- }
- rebuildSourceObservations();
viewport.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
schedule();
return () => {
if (frame !== null) cancelAnimationFrame(frame);
viewport.removeAttribute("data-has-draft-citations");
- stopSourceResize();
stopResize();
- sourceMutations.disconnect();
mutations.disconnect();
viewport.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
From fadc4dc426b91a18433fa8527480e60fe2aeb333 Mon Sep 17 00:00:00 2001
From: maria-rcks
Date: Tue, 10 Nov 2026 05:35:58 +0000
Subject: [PATCH 13/13] fix(web): keep stacked citation markers from
overlapping
---
.../AssistantCitationMarkers.browser.test.tsx | 54 +++++++++++++++++--
.../chat/AssistantCitationMarkers.tsx | 16 +++++-
2 files changed, 66 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
index e4b4011b02c4a..ac32127df3ec0 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.browser.test.tsx
@@ -37,8 +37,8 @@ it.each(["moving sibling", "queued text", "narrow gutter"])(
range.setEnd(source.firstChild!, 5);
return { source, range };
});
- const prompt = serializeAssistantCitation({
- version: 1,
+ const citation = {
+ version: 1 as const,
environmentId: EnvironmentId.make("env"),
threadId: ThreadId.make("thread"),
messageId: MessageId.make("message"),
@@ -47,7 +47,8 @@ it.each(["moving sibling", "queued text", "narrow gutter"])(
end: 5,
prefix: "",
suffix: "",
- });
+ };
+ const prompt = serializeAssistantCitation(citation);
const onOpen = vi.fn();
await using screen = await render(
{
+ source.style.lineHeight = "20px";
+ nextSource.style.lineHeight = "20px";
+ source.style.margin = "0";
+ nextSource.style.margin = "0";
+ content.style.paddingInlineEnd = "32px";
+ content.append(nextSource);
+ });
+ mocks.findAnchor.mockImplementation((_viewport, citation) => {
+ const target = citation.comment === "next line" ? nextSource : source;
+ const range = document.createRange();
+ range.setStart(target.firstChild!, 0);
+ range.setEnd(target.firstChild!, 5);
+ return { source: target, range };
+ });
+ const nextPrompt = serializeAssistantCitation({ ...citation, comment: "next line" });
+ await screen.rerender(
+ ,
+ );
await act(() => {
source.style.width = "100%";
+ nextSource.style.width = "100%";
viewport.dispatchEvent(new Event("scroll"));
});
await vi.waitFor(() => {
@@ -109,7 +136,28 @@ it.each(["moving sibling", "queued text", "narrow gutter"])(
viewport.clientWidth,
);
}
+ const positioned = Array.from(
+ screen.container.querySelectorAll("[data-citation-marker-key]"),
+ (marker) => {
+ expect(marker.style.left).not.toBe("");
+ expect(marker.hidden).toBe(false);
+ return marker.getBoundingClientRect();
+ },
+ );
+ expect(positioned).toHaveLength(3);
+ for (let i = 0; i < positioned.length; i++) {
+ for (const other of positioned.slice(i + 1)) {
+ const rect = positioned[i]!;
+ expect(
+ rect.right <= other.left ||
+ other.right <= rect.left ||
+ rect.bottom <= other.top ||
+ other.bottom <= rect.top,
+ ).toBe(true);
+ }
+ }
});
+ await act(() => nextSource.remove());
}
await act(() => source.remove());
await vi.waitFor(() => {
diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
index 9aab070a26704..988b392ee33a7 100644
--- a/apps/web/src/components/chat/AssistantCitationMarkers.tsx
+++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx
@@ -64,6 +64,7 @@ function CitationMarkers({
frame = null;
const bounds = viewport.getBoundingClientRect();
const occurrences = new Map();
+ const placed: { left: number; right: number; top: number; bottom: number }[] = [];
let resolvedSource = false;
for (const [index, marker] of citations.entries()) {
const { citation, key } = marker;
@@ -96,9 +97,22 @@ function CitationMarkers({
const base = Math.max(0, sourceRight - bounds.left + 3);
const columns = Math.max(1, Math.floor((bounds.width - base + 2) / (width + 2)));
const left = base + (occurrence % columns) * (width + 2);
- const top =
+ let top =
Math.max(0, rect.bottom - bounds.top - height) +
Math.floor(occurrence / columns) * (height + 2);
+ let collision;
+ while (
+ (collision = placed.find(
+ (other) =>
+ left < other.right + 2 &&
+ left + width + 2 > other.left &&
+ top < other.bottom + 2 &&
+ top + height + 2 > other.top,
+ ))
+ ) {
+ top = collision.bottom + 2;
+ }
+ placed.push({ left, right: left + width, top, bottom: top + height });
button.style.left = `${left}px`;
button.style.top = `${top}px`;
}