authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:42:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:42:20-07:00
log9fb4ddb2d1ccad4452b15c2934170da275cb8bc6
treee4a4fd058a60c2e403cfdc2027e1685042cfba89
parent52408973888c87bc314e51e338c79687b1ebf59d
signature Signed by PGP key B5690EEEBB952194

fix(web): an unsaved citation comment survives a question or approval (#19350)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 202 insertions(+), 2 deletions(-)

apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx created+123
...@@ -0,0 +1,123 @@
1// @vitest-environment jsdom
2
3import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
4import {
5 serializeAssistantCitation,
6 withAssistantCitationComment,
7} from "@t3tools/shared/assistantCitations";
8import { act, createRef, useState, type ReactNode } from "react";
9import { createRoot, type Root } from "react-dom/client";
10import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
11
12import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
13
14vi.mock("./chat/AssistantCitationSource", () => ({
15 observeAssistantCitationCommentSource: () => () => {},
16}));
17vi.mock("@tanstack/react-router", () => ({
18 useNavigate: () => () => {},
19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
20}));
21// Keep the real chip and comment editor while replacing floating layers.
22vi.mock("./ui/popover", () => ({
23 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
24 PopoverTrigger: ({ "aria-label": label }: { "aria-label": string }) => (
25 <button aria-label={label} />
26 ),
27 PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
28 PopoverClose: ({ children }: { children: ReactNode }) => <>{children}</>,
29}));
30
31import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
32
33const citation = {
34 version: 1 as const,
35 environmentId: EnvironmentId.make("environment"),
36 threadId: ThreadId.make("thread"),
37 messageId: MessageId.make("source"),
38 text: "hello",
39 start: 0,
40 end: 5,
41 prefix: "",
42 suffix: "",
43};
44const promptBefore = `See ${serializeAssistantCitation(citation)} `;
45
46let root: Root;
47let container: HTMLDivElement;
48
49beforeEach(() => {
50 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
51 container = document.createElement("div");
52 document.body.append(container);
53 root = createRoot(container);
54});
55
56afterEach(async () => {
57 await act(async () => root.unmount());
58 container.remove();
59 vi.unstubAllGlobals();
60});
61
62// Mirrors ChatComposer: a pending question shows its answer in the same editor.
63function Composer({
64 question,
65 editorRef,
66}: {
67 question: boolean;
68 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
69}) {
70 const [prompt, setPrompt] = useState(promptBefore);
71 const [answer, setAnswer] = useState("");
72 return (
73 <>
74 <output data-prompt={prompt} data-answer={answer} />
75 <ComposerPromptEditor
76 editorRef={editorRef}
77 value={question ? answer : prompt}
78 cursor={0}
79 contextRecords={new Map()}
80 skills={[]}
81 disabled={false}
82 placeholder=""
83 onChange={(next) => (question ? setAnswer(next) : setPrompt(next))}
84 onCitationDraftSave={(replacedValue, savedValue) => {
85 if (replacedValue === prompt) setPrompt(savedValue);
86 }}
87 onPaste={() => {}}
88 />
89 </>
90 );
91}
92
93it("keeps an unsaved citation comment when a question takes the composer", async () => {
94 const editorRef = createRef<ComposerPromptEditorHandle>();
95 const drafts = () => container.querySelector("output")?.dataset;
96
97 await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
98 await act(async () =>
99 editorRef.current?.requestCitationComment({
100 previousValue: "See ",
101 value: promptBefore,
102 citationStart: "See ".length,
103 sourceAnchor: {} as AssistantCitationSourceAnchor,
104 insertedSpaces: { before: false, after: true },
105 }),
106 );
107 const textarea = container.querySelector("textarea");
108 if (!textarea) throw new Error("Comment editor did not open");
109 await act(async () => {
110 const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
111 setValue?.call(textarea, "check this");
112 textarea.dispatchEvent(new Event("input", { bubbles: true }));
113 });
114
115 await act(async () => root.render(<Composer question editorRef={editorRef} />));
116 expect(drafts()?.answer).toBe("");
117 await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
118
119 expect(drafts()?.prompt).toBe(
120 `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `,
121 );
122 expect(container.querySelector('[aria-label="Edit citation comment"]')).not.toBeNull();
123});
apps/web/src/components/ComposerPromptEditorTiptap.tsx+65-2
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";2import {
3 Extension,
4 findChildren,
5 InputRule,
6 Node,
7 wrappingInputRule,
8 type JSONContent,
9} from "@tiptap/core";
3import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";10import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
4import StarterKit from "@tiptap/starter-kit";11import StarterKit from "@tiptap/starter-kit";
5import { type Node as ProseMirrorNode } from "@tiptap/pm/model";12import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
...@@ -31,6 +38,7 @@ import {...@@ -31,6 +38,7 @@ import {
31 use,38 use,
32 useCallback,39 useCallback,
33 useEffect,40 useEffect,
41 useEffectEvent,
34 useImperativeHandle,42 useImperativeHandle,
35 useLayoutEffect,43 useLayoutEffect,
36 useMemo,44 useMemo,
...@@ -107,6 +115,7 @@ import {...@@ -107,6 +115,7 @@ import {
107import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";115import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
108import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";116import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
109import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";117import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
118import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
110import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";119import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
111import { importPastedComposerText } from "./composerInlineTokenPaste";120import { importPastedComposerText } from "./composerInlineTokenPaste";
112import { didComposerSelectionChangeVisibly } from "./composerSelection";121import { didComposerSelectionChangeVisibly } from "./composerSelection";
...@@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps {...@@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps {
180 onPageScrollKeyUp?: (key: string) => void;189 onPageScrollKeyUp?: (key: string) => void;
181 onPageScrollRelease?: () => void;190 onPageScrollRelease?: () => void;
182 onCitationSubmitAndSend?: () => void;191 onCitationSubmitAndSend?: () => void;
192 /**
193 * Called when a new controlled value replaces the document while a citation
194 * comment is unsaved. The comment is saved into `replacedValue`, giving
195 * `savedValue`, so the owner of `replacedValue` can keep it.
196 */
197 onCitationDraftSave?: (replacedValue: string, savedValue: string) => void;
183 onPaste: React.ClipboardEventHandler<HTMLElement>;198 onPaste: React.ClipboardEventHandler<HTMLElement>;
184 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;199 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
185}200}
...@@ -204,8 +219,14 @@ type OpenCitationComment = {...@@ -204,8 +219,14 @@ type OpenCitationComment = {
204const ComposerCitationCommentContext = createContext<{219const ComposerCitationCommentContext = createContext<{
205 openComment: OpenCitationComment | null;220 openComment: OpenCitationComment | null;
206 onOpenChange: (citeKey: string, open: boolean) => void;221 onOpenChange: (citeKey: string, open: boolean) => void;
222 onDraftChange: (citeKey: string, comment: string) => void;
207 onSubmitAndSend: () => void;223 onSubmitAndSend: () => void;
208}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });224}>({
225 openComment: null,
226 onOpenChange: () => {},
227 onDraftChange: () => {},
228 onSubmitAndSend: () => {},
229});
209230
210const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);231const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
211232
...@@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {...@@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
472 },493 },
473 ...(removeOnCancel ? { onCancel: onRemove } : {}),494 ...(removeOnCancel ? { onCancel: onRemove } : {}),
474 onSave: onSaveComment,495 onSave: onSaveComment,
496 onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment),
475 onSaveAndSend: (comment) => {497 onSaveAndSend: (comment) => {
476 if (!onSaveComment(comment)) return false;498 if (!onSaveComment(comment)) return false;
477 commentContext.onSubmitAndSend();499 commentContext.onSubmitAndSend();
...@@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
843 onPageScrollKeyUp,865 onPageScrollKeyUp,
844 onPageScrollRelease,866 onPageScrollRelease,
845 onCitationSubmitAndSend,867 onCitationSubmitAndSend,
868 onCitationDraftSave,
846 onPaste,869 onPaste,
847 editorRef,870 editorRef,
848 } = props;871 } = props;
...@@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
928 const hasAppliedControlledSelectionRef = useRef(false);951 const hasAppliedControlledSelectionRef = useRef(false);
929 const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null);952 const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
930 const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null);953 const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null);
954 // The open citation's unsaved comment, saved before a new value replaces the document.
955 const citationDraftRef = useRef<{ key: string; comment: string } | null>(null);
931 const [isEmpty, setIsEmpty] = useState(value.length === 0);956 const [isEmpty, setIsEmpty] = useState(value.length === 0);
932957
933 const citationCommentActions = useMemo(958 const citationCommentActions = useMemo(
934 () => ({959 () => ({
935 openComment: openCitation,960 openComment: openCitation,
936 onOpenChange: (nodeKey: string, open: boolean) => {961 onOpenChange: (nodeKey: string, open: boolean) => {
962 if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null;
937 setOpenCitation((current) => {963 setOpenCitation((current) => {
938 if (open) {964 if (open) {
939 if (current?.key === nodeKey) return current;965 if (current?.key === nodeKey) return current;
...@@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
942 return current?.key === nodeKey ? null : current;968 return current?.key === nodeKey ? null : current;
943 });969 });
944 },970 },
971 onDraftChange: (nodeKey: string, comment: string) => {
972 citationDraftRef.current = { key: nodeKey, comment };
973 },
945 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),974 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),
946 }),975 }),
947 [onCitationSubmitAndSend, openCitation],976 [onCitationSubmitAndSend, openCitation],
...@@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1529 return next;1558 return next;
1530 }, [clampEditorCursor, editor, literalText]);1559 }, [clampEditorCursor, editor, literalText]);
15311560
1561 // A question or approval swaps the controlled value while a citation comment
1562 // is open. Its node view unmounts with the old document, so save the comment
1563 // the way dismissing the popover would, and hand the saved value back.
1564 const saveCitationDraft = useEffectEvent((current: TiptapEditor, replacedValue: string) => {
1565 const draft = citationDraftRef.current;
1566 citationDraftRef.current = null;
1567 if (!draft || !onCitationDraftSave) return;
1568 const [target] = findChildren(
1569 current.state.doc,
1570 (node) => node.type.name === "composer-citation" && node.attrs.citeKey === draft.key,
1571 );
1572 if (!target) return;
1573 const { node, pos } = target;
1574 const citation = node.attrs.citation as AssistantCitation;
1575 const dismissal = resolveAssistantCitationCommentDismissal({
1576 reason: "none",
1577 draft: draft.comment,
1578 savedComment: citation.comment,
1579 });
1580 if (dismissal.kind !== "commit") return;
1581 const next = withAssistantCitationComment(citation, dismissal.comment);
1582 current.view.dispatch(
1583 current.state.tr
1584 .setNodeMarkup(pos, undefined, {
1585 ...node.attrs,
1586 citation: next,
1587 source: serializeAssistantCitation(next),
1588 })
1589 .setMeta("addToHistory", false),
1590 );
1591 onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value);
1592 });
1593
1532 // Controlled value/cursor from the store (history recall, chip insertion…).1594 // Controlled value/cursor from the store (history recall, chip insertion…).
1533 useLayoutEffect(() => {1595 useLayoutEffect(() => {
1534 if (!editor) return;1596 if (!editor) return;
...@@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1563 const pendingCitation =1625 const pendingCitation =
1564 citationRequestRef.current?.value === value ? citationRequestRef.current : null;1626 citationRequestRef.current?.value === value ? citationRequestRef.current : null;
1565 if (previousSnapshot.value !== value) {1627 if (previousSnapshot.value !== value) {
1628 saveCitationDraft(editor, previousSnapshot.value);
1566 editor.commands.setContent(1629 editor.commands.setContent(
1567 buildDocJson(value, skillLabelFor, { styling: richText, literalText }),1630 buildDocJson(value, skillLabelFor, { styling: richText, literalText }),
1568 {1631 {
apps/web/src/components/chat/AssistantCitationChip.tsx+3
...@@ -47,6 +47,8 @@ export function AssistantCitationChip({...@@ -47,6 +47,8 @@ export function AssistantCitationChip({
47 onCancel?: () => void;47 onCancel?: () => void;
48 onSave: (comment: string) => boolean;48 onSave: (comment: string) => boolean;
49 onSaveAndSend?: (comment: string) => boolean;49 onSaveAndSend?: (comment: string) => boolean;
50 /** Reports each keystroke of the unsaved comment. */
51 onDraftChange?: (comment: string) => void;
50 /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */52 /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */
51 onRestoreFocus?: () => void;53 onRestoreFocus?: () => void;
52 };54 };
...@@ -211,6 +213,7 @@ export function AssistantCitationChip({...@@ -211,6 +213,7 @@ export function AssistantCitationChip({
211 inputRef={commentInputRef}213 inputRef={commentInputRef}
212 onDraftChange={(comment) => {214 onDraftChange={(comment) => {
213 draftCommentRef.current = comment;215 draftCommentRef.current = comment;
216 commentEditor.onDraftChange?.(comment);
214 }}217 }}
215 onSubmit={(comment) => {218 onSubmit={(comment) => {
216 if (!commentEditor.onSave(comment)) return false;219 if (!commentEditor.onSave(comment)) return false;
apps/web/src/components/chat/ChatComposer.tsx+11
...@@ -4358,6 +4358,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4358,6 +4358,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4358 }),4358 }),
4359 );4359 );
4360 }, [phase, settings.followUpBehavior, submitComposer]);4360 }, [phase, settings.followUpBehavior, submitComposer]);
4361 // A question or approval took the editor while a citation comment was
4362 // unsaved. Keep the comment in the prompt that comes back afterward.
4363 const saveCitationDraftToPrompt = useCallback(
4364 (replacedValue: string, savedValue: string) => {
4365 if (replacedValue !== prompt) return;
4366 if (promptRef.current === replacedValue) promptRef.current = savedValue;
4367 setPrompt(savedValue);
4368 },
4369 [prompt, promptRef, setPrompt],
4370 );
4361 const compactThreadContext = useCallback(() => {4371 const compactThreadContext = useCallback(() => {
4362 if (4372 if (
4363 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||4373 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
...@@ -7591,6 +7601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7591,6 +7601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7591 onPageScrollKeyUp={onPageScrollKeyUp}7601 onPageScrollKeyUp={onPageScrollKeyUp}
7592 onPageScrollRelease={onPageScrollRelease}7602 onPageScrollRelease={onPageScrollRelease}
7593 onCitationSubmitAndSend={submitCitationAndSend}7603 onCitationSubmitAndSend={submitCitationAndSend}
7604 onCitationDraftSave={saveCitationDraftToPrompt}
7594 onPaste={onComposerPaste}7605 onPaste={onComposerPaste}
7595 placeholder={7606 placeholder={
7596 isComposerApprovalState7607 isComposerApprovalState