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 @@
11import { 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";
310import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
411import StarterKit from "@tiptap/starter-kit";
512import { type Node as ProseMirrorNode } from "@tiptap/pm/model";
......@@ -31,6 +38,7 @@ import {
3138 use,
3239 useCallback,
3340 useEffect,
41 useEffectEvent,
3442 useImperativeHandle,
3543 useLayoutEffect,
3644 useMemo,
......@@ -107,6 +115,7 @@ import {
107115import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
108116import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
109117import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
118import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
110119import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
111120import { importPastedComposerText } from "./composerInlineTokenPaste";
112121import { didComposerSelectionChangeVisibly } from "./composerSelection";
......@@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps {
180189 onPageScrollKeyUp?: (key: string) => void;
181190 onPageScrollRelease?: () => void;
182191 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;
183198 onPaste: React.ClipboardEventHandler<HTMLElement>;
184199 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
185200}
......@@ -204,8 +219,14 @@ type OpenCitationComment = {
204219const ComposerCitationCommentContext = createContext<{
205220 openComment: OpenCitationComment | null;
206221 onOpenChange: (citeKey: string, open: boolean) => void;
222 onDraftChange: (citeKey: string, comment: string) => void;
207223 onSubmitAndSend: () => void;
208}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });
224}>({
225 openComment: null,
226 onOpenChange: () => {},
227 onDraftChange: () => {},
228 onSubmitAndSend: () => {},
229});
209230
210231const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]);
211232
......@@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
472493 },
473494 ...(removeOnCancel ? { onCancel: onRemove } : {}),
474495 onSave: onSaveComment,
496 onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment),
475497 onSaveAndSend: (comment) => {
476498 if (!onSaveComment(comment)) return false;
477499 commentContext.onSubmitAndSend();
......@@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
843865 onPageScrollKeyUp,
844866 onPageScrollRelease,
845867 onCitationSubmitAndSend,
868 onCitationDraftSave,
846869 onPaste,
847870 editorRef,
848871 } = props;
......@@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
928951 const hasAppliedControlledSelectionRef = useRef(false);
929952 const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null);
930953 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);
931956 const [isEmpty, setIsEmpty] = useState(value.length === 0);
932957
933958 const citationCommentActions = useMemo(
934959 () => ({
935960 openComment: openCitation,
936961 onOpenChange: (nodeKey: string, open: boolean) => {
962 if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null;
937963 setOpenCitation((current) => {
938964 if (open) {
939965 if (current?.key === nodeKey) return current;
......@@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
942968 return current?.key === nodeKey ? null : current;
943969 });
944970 },
971 onDraftChange: (nodeKey: string, comment: string) => {
972 citationDraftRef.current = { key: nodeKey, comment };
973 },
945974 onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}),
946975 }),
947976 [onCitationSubmitAndSend, openCitation],
......@@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15291558 return next;
15301559 }, [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
15321594 // Controlled value/cursor from the store (history recall, chip insertion…).
15331595 useLayoutEffect(() => {
15341596 if (!editor) return;
......@@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15631625 const pendingCitation =
15641626 citationRequestRef.current?.value === value ? citationRequestRef.current : null;
15651627 if (previousSnapshot.value !== value) {
1628 saveCitationDraft(editor, previousSnapshot.value);
15661629 editor.commands.setContent(
15671630 buildDocJson(value, skillLabelFor, { styling: richText, literalText }),
15681631 {
apps/web/src/components/chat/AssistantCitationChip.tsx+3
......@@ -47,6 +47,8 @@ export function AssistantCitationChip({
4747 onCancel?: () => void;
4848 onSave: (comment: string) => boolean;
4949 onSaveAndSend?: (comment: string) => boolean;
50 /** Reports each keystroke of the unsaved comment. */
51 onDraftChange?: (comment: string) => void;
5052 /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */
5153 onRestoreFocus?: () => void;
5254 };
......@@ -211,6 +213,7 @@ export function AssistantCitationChip({
211213 inputRef={commentInputRef}
212214 onDraftChange={(comment) => {
213215 draftCommentRef.current = comment;
216 commentEditor.onDraftChange?.(comment);
214217 }}
215218 onSubmit={(comment) => {
216219 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)
43584358 }),
43594359 );
43604360 }, [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 );
43614371 const compactThreadContext = useCallback(() => {
43624372 if (
43634373 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
......@@ -7591,6 +7601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
75917601 onPageScrollKeyUp={onPageScrollKeyUp}
75927602 onPageScrollRelease={onPageScrollRelease}
75937603 onCitationSubmitAndSend={submitCitationAndSend}
7604 onCitationDraftSave={saveCitationDraftToPrompt}
75947605 onPaste={onComposerPaste}
75957606 placeholder={
75967607 isComposerApprovalState