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) => ( + ))} ); 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={