From 41bb4da54bd01a3e53a6ef65c96bb2c4f494ea6f Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 24 Sep 2026 03:10:05 +0200 Subject: [PATCH 1/8] fix(web): an unsaved citation comment survives a provider question The comment lived only in the editor's local state. When a provider question arrives the composer lends its editor to the answer, the document is replaced, and the citation's node view unmounts without any dismissal, so the text typed so far was gone once the question was answered. The draft is now kept outside the node view, keyed by the citation, and the editor resumes it when it comes back; the dismissal rules see it as typed. It is dropped once the comment is saved or cancelled. --- .../chat/AssistantCitationChip.test.tsx | 35 +++++++++++++++++++ .../components/chat/AssistantCitationChip.tsx | 28 ++++++++++++--- .../chat/AssistantCitationCommentEditor.tsx | 5 ++- .../chat/assistantCitationCommentDrafts.ts | 20 +++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/chat/assistantCitationCommentDrafts.ts diff --git a/apps/web/src/components/chat/AssistantCitationChip.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.test.tsx index 7d664f237983..d73a78fa4aff 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.test.tsx @@ -101,6 +101,41 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("citation comment draft across a remount", () => { + it("resumes an unsaved comment when the chip is mounted again", () => { + // The composer editor replaces its document when a provider question + // borrows it, which unmounts the node view without any dismissal. + mount(); + typeComment("still typing"); + act(() => renderer.unmount()); + + const onSave = mount(); + expect(renderer.root.findByType("textarea").props.value).toBe("still typing"); + clickButton("Save"); + expect(onSave).toHaveBeenCalledWith("still typing"); + }); + + it("forgets the draft once it was saved", () => { + mount(); + typeComment("saved now"); + clickButton("Save"); + act(() => renderer.unmount()); + + mount(); + expect(renderer.root.findByType("textarea").props.value).toBe(""); + }); + + it("forgets the draft once it was cancelled", () => { + mount(); + typeComment("never mind"); + clickButton("Cancel"); + act(() => renderer.unmount()); + + mount(); + expect(renderer.root.findByType("textarea").props.value).toBe(""); + }); +}); + describe("citation comment source disappearance", () => { it("preserves an over-length draft at the composer until it can be shortened and saved", () => { const onSave = mount(); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index e03872553a88..fbb8960028c2 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -21,6 +21,11 @@ import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; +import { + clearAssistantCitationCommentDraft, + readAssistantCitationCommentDraft, + writeAssistantCitationCommentDraft, +} from "./assistantCitationCommentDrafts"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; @@ -52,17 +57,25 @@ export function AssistantCitationChip({ const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor; + // The draft outlives this chip (see assistantCitationCommentDrafts), so an + // editor that comes back after the composer was borrowed for a question + // resumes it, and the dismissal rules see it as typed. + const draftKey = serializeAssistantCitation(citation); useEffect(() => { - if (!commentOpen) draftCommentRef.current = null; - }, [commentOpen]); + draftCommentRef.current = commentOpen ? readAssistantCitationCommentDraft(draftKey) : null; + }, [commentOpen, draftKey]); const settleDraftOnClose = (reason: string): boolean => { const dismissal = resolveAssistantCitationCommentDismissal({ reason, draft: draftCommentRef.current, savedComment: citation.comment, }); - if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true; - return dismissal.kind !== "keep-open"; + if (dismissal.kind === "keep-open") return false; + if (dismissal.kind === "commit" && !(commentEditor?.onSave(dismissal.comment) ?? true)) { + return false; + } + clearAssistantCitationCommentDraft(draftKey); + return true; }; const onSourceUnavailable = useEffectEvent(() => { if (!sourceAnchor) return; @@ -195,14 +208,17 @@ export function AssistantCitationChip({ onPointerDown={(event) => event.stopPropagation()} > { draftCommentRef.current = comment; + writeAssistantCitationCommentDraft(draftKey, comment); }} onSubmit={(comment) => { if (!commentEditor.onSave(comment)) return false; + clearAssistantCitationCommentDraft(draftKey); commentEditor.onOpenChange(false); return true; }} @@ -210,12 +226,14 @@ export function AssistantCitationChip({ ? { onSubmitAndSend: (comment: string) => { if (!commentEditor.onSaveAndSend?.(comment)) return false; + clearAssistantCitationCommentDraft(draftKey); commentEditor.onOpenChange(false); return true; }, } : {})} onCancel={() => { + clearAssistantCitationCommentDraft(draftKey); if (commentEditor.onCancel) { commentEditor.onCancel(); } else { diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index d9fd60645618..ec435dd02aa7 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -10,15 +10,18 @@ export function AssistantCitationCommentEditor({ onSubmitAndSend, onCancel, onDraftChange, + draft, }: { citation: AssistantCitation; + /** An unsaved comment from an earlier mount of this editor, shown instead of the saved one. */ + draft?: string | null; inputRef?: Ref; onSubmit: (comment: string) => boolean; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; onDraftChange?: (comment: string) => void; }) { - const [comment, setComment] = useState(citation.comment ?? ""); + const [comment, setComment] = useState(draft ?? citation.comment ?? ""); const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; const submit = () => { if (!commentTooLong) onSubmit(comment); diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts new file mode 100644 index 000000000000..52a1b1fe0036 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -0,0 +1,20 @@ +/** + * Unsaved citation comments, keyed by the serialized citation. The comment + * editor lives inside the composer's citation node view, and the editor + * replaces its whole document when a provider question borrows it for the + * answer, which unmounts the node view without any dismissal. The draft is + * kept here across that, and dropped once it is saved or discarded. + */ +const drafts = new Map(); + +export function readAssistantCitationCommentDraft(key: string): string | null { + return drafts.get(key) ?? null; +} + +export function writeAssistantCitationCommentDraft(key: string, draft: string): void { + drafts.set(key, draft); +} + +export function clearAssistantCitationCommentDraft(key: string): void { + drafts.delete(key); +} From fe7b2edb6be052d1bdbab144d0a674082b0fd102 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 24 Sep 2026 03:13:27 +0200 Subject: [PATCH 2/8] fix(web): key citation comment drafts by the node, not the citation Two identical citations serialize the same, so one could resume the other's unsaved comment. The composer node view passes its own citeKey. --- .../components/ComposerPromptEditorTiptap.tsx | 1 + .../chat/AssistantCitationChip.test.tsx | 34 +++++++++++++++++-- .../components/chat/AssistantCitationChip.tsx | 4 ++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1926e4dd279f..7f9c705de45c 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -425,6 +425,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { commentContext.onOpenChange(citeKey, open); }, ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + draftKey: citeKey, onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; diff --git a/apps/web/src/components/chat/AssistantCitationChip.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.test.tsx index d73a78fa4aff..f595c87c15d2 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.test.tsx @@ -56,13 +56,19 @@ const sourceAnchor = { let renderer: ReactTestRenderer; -function mount(onSave = vi.fn(() => true)) { +function mount(onSave = vi.fn(() => true), draftKey?: string) { function Composer() { const [open, setOpen] = useState(true); return ( ); } @@ -115,6 +121,30 @@ describe("citation comment draft across a remount", () => { expect(onSave).toHaveBeenCalledWith("still typing"); }); + it("keeps the drafts of two identical citations apart by their node key", () => { + mount( + vi.fn(() => true), + "cite-1", + ); + typeComment("for the first"); + act(() => renderer.unmount()); + + mount( + vi.fn(() => true), + "cite-2", + ); + expect(renderer.root.findByType("textarea").props.value).toBe(""); + clickButton("Cancel"); + act(() => renderer.unmount()); + + mount( + vi.fn(() => true), + "cite-1", + ); + expect(renderer.root.findByType("textarea").props.value).toBe("for the first"); + clickButton("Cancel"); + }); + it("forgets the draft once it was saved", () => { mount(); typeComment("saved now"); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index fbb8960028c2..7b3014ef4819 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -46,6 +46,8 @@ export function AssistantCitationChip({ onSaveAndSend?: (comment: string) => boolean; /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ onRestoreFocus?: () => void; + /** Tells two otherwise identical citations apart; the serialized citation is the fallback. */ + draftKey?: string; }; }) { const navigate = useNavigate(); @@ -60,7 +62,7 @@ export function AssistantCitationChip({ // The draft outlives this chip (see assistantCitationCommentDrafts), so an // editor that comes back after the composer was borrowed for a question // resumes it, and the dismissal rules see it as typed. - const draftKey = serializeAssistantCitation(citation); + const draftKey = commentEditor?.draftKey ?? serializeAssistantCitation(citation); useEffect(() => { draftCommentRef.current = commentOpen ? readAssistantCitationCommentDraft(draftKey) : null; }, [commentOpen, draftKey]); From c9e0f82cecee69bd1f4bba3087e26d24229dbec3 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 24 Sep 2026 03:38:00 +0200 Subject: [PATCH 3/8] fix(web): key citation comment drafts by citation and order, which survives a rebuild The composer rebuilds its document from the prompt text whenever its value changes and gives every node a fresh random key, so a draft keyed by the node was lost in the very rebuild a provider question causes. The key is now the serialized citation plus how many identical ones precede it, which the same prompt rebuilds identically. --- .../components/ComposerPromptEditorTiptap.tsx | 17 +++++++++- .../components/chat/AssistantCitationChip.tsx | 2 +- .../assistantCitationCommentDrafts.test.ts | 34 +++++++++++++++++++ .../chat/assistantCitationCommentDrafts.ts | 23 +++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 7f9c705de45c..ec16f61b881f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -68,6 +68,7 @@ import { basenameOfPath } from "~/pierre-icons"; import { FileTagChipContent } from "./chat/FileTagChip"; import { SkillChipIcon } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { assistantCitationDraftKey } from "./chat/assistantCitationCommentDrafts"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { ContextChipPopover } from "./contextChipParts"; import { Button } from "./ui/button"; @@ -338,6 +339,20 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { const commentContext = use(ComposerCitationCommentContext); const citation = node.attrs.citation as AssistantCitation; const citeKey = node.attrs.citeKey as string; + // Unsaved comments are keyed by the citation and its place among identical + // ones, which survives the rebuild a provider question causes; citeKey does not. + const draftKey = useMemo(() => { + const pos = typeof getPos === "function" ? getPos() : undefined; + const citationsBefore: Array = []; + if (typeof pos === "number") { + editor.state.doc.nodesBetween(0, pos, (earlier) => { + if (earlier.type.name === "composer-citation") { + citationsBefore.push(earlier.attrs.citation as AssistantCitation); + } + }); + } + return assistantCitationDraftKey(citation, citationsBefore); + }, [citation, editor.state.doc, getPos]); const commentTarget = commentContext.openComment?.key === citeKey ? commentContext.openComment : null; @@ -425,7 +440,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { commentContext.onOpenChange(citeKey, open); }, ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), - draftKey: citeKey, + draftKey, onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 7b3014ef4819..c513974876f1 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -46,7 +46,7 @@ export function AssistantCitationChip({ onSaveAndSend?: (comment: string) => boolean; /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ onRestoreFocus?: () => void; - /** Tells two otherwise identical citations apart; the serialized citation is the fallback. */ + /** Where the unsaved comment is kept across a remount; the serialized citation is the fallback. */ draftKey?: string; }; }) { diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts new file mode 100644 index 000000000000..15b77f07cc63 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts @@ -0,0 +1,34 @@ +import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { assistantCitationDraftKey } from "./assistantCitationCommentDrafts"; + +const citation: AssistantCitation = { + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("source"), + text: "hello", + start: 0, + end: 5, + prefix: "", + suffix: "", +}; +const other: AssistantCitation = { ...citation, text: "world", start: 6, end: 11 }; + +describe("assistantCitationDraftKey", () => { + it("is the same for the same citation whatever nodes come before it", () => { + expect(assistantCitationDraftKey(citation, [])).toBe( + assistantCitationDraftKey(citation, [other]), + ); + }); + + it("tells identical citations apart by their order in the document", () => { + const first = assistantCitationDraftKey(citation, []); + const second = assistantCitationDraftKey(citation, [citation]); + const third = assistantCitationDraftKey(citation, [other, citation, citation]); + expect(new Set([first, second, third]).size).toBe(3); + // The same prompt rebuilt in the same order yields the same keys again. + expect(assistantCitationDraftKey(citation, [citation])).toBe(second); + }); +}); diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts index 52a1b1fe0036..312eafd23402 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -1,3 +1,6 @@ +import type { AssistantCitation } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; + /** * Unsaved citation comments, keyed by the serialized citation. The comment * editor lives inside the composer's citation node view, and the editor @@ -7,6 +10,26 @@ */ const drafts = new Map(); +/** + * The key a citation's draft lives under. The composer rebuilds its document + * from the prompt text whenever its value changes, and every node gets a fresh + * random key then, so the key cannot be the node's. Two identical citations + * still need separate drafts, so the serialized citation is suffixed with how + * many identical ones come before it; a rebuild from the same prompt keeps + * that order, and so keeps the key. + */ +export function assistantCitationDraftKey( + citation: AssistantCitation, + citationsBefore: ReadonlyArray, +): string { + const serialized = serializeAssistantCitation(citation); + let ordinal = 0; + for (const earlier of citationsBefore) { + if (serializeAssistantCitation(earlier) === serialized) ordinal += 1; + } + return `${serialized}#${ordinal}`; +} + export function readAssistantCitationCommentDraft(key: string): string | null { return drafts.get(key) ?? null; } From 3e70d00e28be85bbc7e6903767a7afc382833016 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 24 Sep 2026 03:51:02 +0200 Subject: [PATCH 4/8] fix(web): scope citation comment drafts to their composer The draft map is shared, so the same citation at the same place in another thread's composer could resume or discard a draft that was not its own. The composer passes its target key down and it becomes part of the key. --- .../src/components/ComposerPromptEditorTiptap.tsx | 13 +++++++++---- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../chat/assistantCitationCommentDrafts.test.ts | 6 ++++++ .../chat/assistantCitationCommentDrafts.ts | 4 +++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index ec16f61b881f..f93cb503858a 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -149,6 +149,8 @@ export interface ComposerPromptEditorProps { onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; onCitationSubmitAndSend?: () => void; + /** Keeps this composer's unsaved citation comments apart from another thread's. */ + citationDraftScope?: string; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -170,7 +172,8 @@ const ComposerCitationCommentContext = createContext<{ openComment: OpenCitationComment | null; onOpenChange: (citeKey: string, open: boolean) => void; onSubmitAndSend: () => void; -}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); + draftScope: string; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {}, draftScope: "" }); const RichComposerSkillsContext = createContext>([]); @@ -351,8 +354,8 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { } }); } - return assistantCitationDraftKey(citation, citationsBefore); - }, [citation, editor.state.doc, getPos]); + return assistantCitationDraftKey(citation, citationsBefore, commentContext.draftScope); + }, [citation, commentContext.draftScope, editor.state.doc, getPos]); const commentTarget = commentContext.openComment?.key === citeKey ? commentContext.openComment : null; @@ -648,6 +651,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { onPageScrollKeyUp, onPageScrollRelease, onCitationSubmitAndSend, + citationDraftScope, onPaste, editorRef, } = props; @@ -729,8 +733,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }); }, onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + draftScope: citationDraftScope ?? "", }), - [onCitationSubmitAndSend, openCitation], + [citationDraftScope, onCitationSubmitAndSend, openCitation], ); const handleEditorChange = useCallback((updated: TiptapEditor) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a5ca8d1d5a7c..eeff01a87f5a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7323,6 +7323,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} onCitationSubmitAndSend={submitCitationAndSend} + citationDraftScope={composerTargetKey(composerDraftTarget)} onPaste={onComposerPaste} placeholder={ isComposerApprovalState diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts index 15b77f07cc63..db3d507f94cf 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts @@ -31,4 +31,10 @@ describe("assistantCitationDraftKey", () => { // The same prompt rebuilt in the same order yields the same keys again. expect(assistantCitationDraftKey(citation, [citation])).toBe(second); }); + + it("keeps the same citation apart across composers", () => { + expect(assistantCitationDraftKey(citation, [], "thread-a")).not.toBe( + assistantCitationDraftKey(citation, [], "thread-b"), + ); + }); }); diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts index 312eafd23402..29340c18fc9c 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -21,13 +21,15 @@ const drafts = new Map(); export function assistantCitationDraftKey( citation: AssistantCitation, citationsBefore: ReadonlyArray, + /** The composer the draft belongs to, so another thread's composer never resumes it. */ + scope = "", ): string { const serialized = serializeAssistantCitation(citation); let ordinal = 0; for (const earlier of citationsBefore) { if (serializeAssistantCitation(earlier) === serialized) ordinal += 1; } - return `${serialized}#${ordinal}`; + return `${scope}\n${serialized}#${ordinal}`; } export function readAssistantCitationCommentDraft(key: string): string | null { From 70749d974081ba017953efc6bb471e8f39ccbf92 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Fri, 25 Sep 2026 22:02:27 +0200 Subject: [PATCH 5/8] fix(web): a sent prompt drops its unsaved citation comments --- apps/web/src/components/ChatView.tsx | 13 +++++++++- .../assistantCitationCommentDrafts.test.ts | 24 ++++++++++++++++++- .../chat/assistantCitationCommentDrafts.ts | 13 ++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8fbe528b34b4..944e5910ee32 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -347,6 +347,8 @@ import { composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, + composerTargetKey, + type ComposerThreadTarget, type DraftThreadEnvMode, useComposerDraftStore, DraftId, @@ -409,6 +411,7 @@ import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { clearAssistantCitationCommentDraftsForComposer } from "./chat/assistantCitationCommentDrafts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; @@ -1739,7 +1742,15 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); + // A sent prompt takes its unsaved citation comments with it (see assistantCitationCommentDrafts). + const clearComposerDraftContent = useCallback( + (target: ComposerThreadTarget) => { + clearComposerContent(target); + clearAssistantCitationCommentDraftsForComposer(composerTargetKey(target)); + }, + [clearComposerContent], + ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts index db3d507f94cf..9e33a02633d2 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts @@ -1,7 +1,12 @@ import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { assistantCitationDraftKey } from "./assistantCitationCommentDrafts"; +import { + assistantCitationDraftKey, + clearAssistantCitationCommentDraftsForComposer, + readAssistantCitationCommentDraft, + writeAssistantCitationCommentDraft, +} from "./assistantCitationCommentDrafts"; const citation: AssistantCitation = { version: 1, @@ -38,3 +43,20 @@ describe("assistantCitationDraftKey", () => { ); }); }); + +describe("clearAssistantCitationCommentDraftsForComposer", () => { + it("drops the sent composer's drafts and keeps every other composer's", () => { + const sent = assistantCitationDraftKey(citation, [], "thread-a"); + const sentDuplicate = assistantCitationDraftKey(citation, [citation], "thread-a"); + const elsewhere = assistantCitationDraftKey(citation, [], "thread-b"); + writeAssistantCitationCommentDraft(sent, "first"); + writeAssistantCitationCommentDraft(sentDuplicate, "second"); + writeAssistantCitationCommentDraft(elsewhere, "other"); + + clearAssistantCitationCommentDraftsForComposer("thread-a"); + + expect(readAssistantCitationCommentDraft(sent)).toBeNull(); + expect(readAssistantCitationCommentDraft(sentDuplicate)).toBeNull(); + expect(readAssistantCitationCommentDraft(elsewhere)).toBe("other"); + }); +}); diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts index 29340c18fc9c..31a6315ca515 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -43,3 +43,16 @@ export function writeAssistantCitationCommentDraft(key: string, draft: string): export function clearAssistantCitationCommentDraft(key: string): void { drafts.delete(key); } + +/** + * Drops every draft a composer still holds once its prompt has been sent. A + * popover that stayed open through the send (a comment over the length limit + * keeps it open) would otherwise hand its text to the next prompt that cites + * the same text in the same composer. + */ +export function clearAssistantCitationCommentDraftsForComposer(scope: string): void { + const prefix = `${scope}\n`; + for (const key of drafts.keys()) { + if (key.startsWith(prefix)) drafts.delete(key); + } +} From 421fd369a1817187946a64306788d933d47d1cb4 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Fri, 25 Sep 2026 22:14:38 +0200 Subject: [PATCH 6/8] fix(web): a failed send gives its citation comment drafts back --- apps/web/src/components/ChatView.tsx | 21 ++++++++++----- .../assistantCitationCommentDrafts.test.ts | 15 ++++++++--- .../chat/assistantCitationCommentDrafts.ts | 27 +++++++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 944e5910ee32..2d2218601b36 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -411,7 +411,11 @@ import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { clearAssistantCitationCommentDraftsForComposer } from "./chat/assistantCitationCommentDrafts"; +import { + type AssistantCitationCommentDraftEntries, + restoreAssistantCitationCommentDrafts, + takeAssistantCitationCommentDraftsForComposer, +} from "./chat/assistantCitationCommentDrafts"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; @@ -1743,11 +1747,12 @@ export default function ChatView(props: ChatViewProps) { (store) => store.setInteractionMode, ); const clearComposerContent = useComposerDraftStore((store) => store.clearComposerContent); - // A sent prompt takes its unsaved citation comments with it (see assistantCitationCommentDrafts). + // A sent prompt takes its unsaved citation comments with it; a send that + // fails hands them back with the prompt (see assistantCitationCommentDrafts). const clearComposerDraftContent = useCallback( (target: ComposerThreadTarget) => { clearComposerContent(target); - clearAssistantCitationCommentDraftsForComposer(composerTargetKey(target)); + return takeAssistantCitationCommentDraftsForComposer(composerTargetKey(target)); }, [clearComposerContent], ); @@ -8453,7 +8458,7 @@ export default function ChatView(props: ChatViewProps) { const followUpPreviewAnnotations = [...composerPreviewAnnotations]; const followUpThreadContexts = [...composerThreadContexts]; promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); + const followUpCitationDrafts = clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); const followUpSent = await onSubmitPlanFollowUp({ text: followUp.text, @@ -8467,6 +8472,7 @@ export default function ChatView(props: ChatViewProps) { }); if (!followUpSent) { promptRef.current = followUpPromptSnapshot; + restoreAssistantCitationCommentDrafts(followUpCitationDrafts); composerTerminalContextsRef.current = [...followUpTerminalContexts]; restorePlanFollowUpComposer({ snapshot: { @@ -8757,6 +8763,7 @@ export default function ChatView(props: ChatViewProps) { if (multipleModelSelections !== null) { const failedSelections: ModelSelection[] = []; let clearedDraft = false; + let clearedCitationDrafts: AssistantCitationCommentDraftEntries = []; let releasedComposer = false; let canRestoreDraft = () => false; let startedCount = 0; @@ -8777,7 +8784,7 @@ export default function ChatView(props: ChatViewProps) { "New thread", ); promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); + clearedCitationDrafts = clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); clearedDraft = true; const clearedDraftSnapshot = useComposerDraftStore @@ -8946,6 +8953,7 @@ export default function ChatView(props: ChatViewProps) { setMultipleModelSelections(failedSelections); if (clearedDraft) { setComposerDraftPrompt(composerDraftTarget, messageTextForSend); + restoreAssistantCitationCommentDrafts(clearedCitationDrafts); addComposerDraftImages( composerDraftTarget, composerImagesSnapshot.map(cloneComposerImageForRetry), @@ -9087,7 +9095,7 @@ export default function ChatView(props: ChatViewProps) { ); } promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); + const sentCitationDrafts = clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); let firstComposerImageName: string | null = null; @@ -9332,6 +9340,7 @@ export default function ChatView(props: ChatViewProps) { return next.length === existing.length ? existing : next; }); promptRef.current = messageTextForSend; + restoreAssistantCitationCommentDrafts(sentCitationDrafts); const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerFilesRef.current = composerFilesSnapshot; diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts index 9e33a02633d2..f2684cb7adb8 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from "vite-plus/test"; import { assistantCitationDraftKey, - clearAssistantCitationCommentDraftsForComposer, readAssistantCitationCommentDraft, + restoreAssistantCitationCommentDrafts, + takeAssistantCitationCommentDraftsForComposer, writeAssistantCitationCommentDraft, } from "./assistantCitationCommentDrafts"; @@ -44,8 +45,8 @@ describe("assistantCitationDraftKey", () => { }); }); -describe("clearAssistantCitationCommentDraftsForComposer", () => { - it("drops the sent composer's drafts and keeps every other composer's", () => { +describe("takeAssistantCitationCommentDraftsForComposer", () => { + it("takes the sent composer's drafts, keeps every other composer's, and restores what it took", () => { const sent = assistantCitationDraftKey(citation, [], "thread-a"); const sentDuplicate = assistantCitationDraftKey(citation, [citation], "thread-a"); const elsewhere = assistantCitationDraftKey(citation, [], "thread-b"); @@ -53,10 +54,16 @@ describe("clearAssistantCitationCommentDraftsForComposer", () => { writeAssistantCitationCommentDraft(sentDuplicate, "second"); writeAssistantCitationCommentDraft(elsewhere, "other"); - clearAssistantCitationCommentDraftsForComposer("thread-a"); + const taken = takeAssistantCitationCommentDraftsForComposer("thread-a"); expect(readAssistantCitationCommentDraft(sent)).toBeNull(); expect(readAssistantCitationCommentDraft(sentDuplicate)).toBeNull(); expect(readAssistantCitationCommentDraft(elsewhere)).toBe("other"); + + // A failed send gives the prompt back, and its drafts with it. + restoreAssistantCitationCommentDrafts(taken); + + expect(readAssistantCitationCommentDraft(sent)).toBe("first"); + expect(readAssistantCitationCommentDraft(sentDuplicate)).toBe("second"); }); }); diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts index 31a6315ca515..3e9f5720b2f5 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -44,15 +44,32 @@ export function clearAssistantCitationCommentDraft(key: string): void { drafts.delete(key); } +export type AssistantCitationCommentDraftEntries = ReadonlyArray< + readonly [key: string, draft: string] +>; + /** - * Drops every draft a composer still holds once its prompt has been sent. A + * Takes every draft a composer still holds once its prompt has been sent. A * popover that stayed open through the send (a comment over the length limit * keeps it open) would otherwise hand its text to the next prompt that cites - * the same text in the same composer. + * the same text in the same composer. A send that fails gives the prompt back + * and restores these with it. */ -export function clearAssistantCitationCommentDraftsForComposer(scope: string): void { +export function takeAssistantCitationCommentDraftsForComposer( + scope: string, +): AssistantCitationCommentDraftEntries { const prefix = `${scope}\n`; - for (const key of drafts.keys()) { - if (key.startsWith(prefix)) drafts.delete(key); + const taken: Array = []; + for (const [key, draft] of drafts) { + if (!key.startsWith(prefix)) continue; + taken.push([key, draft]); + drafts.delete(key); } + return taken; +} + +export function restoreAssistantCitationCommentDrafts( + entries: AssistantCitationCommentDraftEntries, +): void { + for (const [key, draft] of entries) drafts.set(key, draft); } From 70141ec92e245e1d0f3c56f61b76289a85ecdd55 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 1 Oct 2026 18:40:51 +0200 Subject: [PATCH 7/8] fix(web): a question or approval commits unsaved citation comments onto their chips --- apps/web/src/components/chat/ChatComposer.tsx | 17 +++++++ .../chat/assistantCitationCommentDrafts.ts | 49 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eeff01a87f5a..fc328d463d62 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -320,6 +320,7 @@ import { getComposerSubmissionValidationMessage, submitComposerDraft, } from "./composerSubmission"; +import { commitAssistantCitationCommentDrafts } from "./assistantCitationCommentDrafts"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { pendingDraftWork } from "./pendingDraftWork"; @@ -2954,6 +2955,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, setComposerDraftPrompt], ); + // A question or an approval borrows the prompt editor, which unmounts the + // citation chips without dismissing an open comment. The unsaved comments are + // written onto their citations first, as a dismissal would have, so the chips + // come back with them and Send includes them. + const editorShowsPrompt = !isComposerApprovalState && !activePendingProgress; + useEffect(() => { + if (editorShowsPrompt) return; + const committed = commitAssistantCitationCommentDrafts( + promptRef.current, + composerDraftTargetKey, + ); + if (committed === promptRef.current) return; + promptRef.current = committed; + setPrompt(committed); + }, [composerDraftTargetKey, editorShowsPrompt, promptRef, setPrompt]); + const addComposerImage = useCallback( (image: ComposerImageAttachment) => addComposerDraftImages(attachmentDraftTarget, [image]), [attachmentDraftTarget, addComposerDraftImages], diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts index 3e9f5720b2f5..c4077abbd981 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.ts @@ -1,5 +1,11 @@ import type { AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { + collectAssistantCitations, + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; + +import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; /** * Unsaved citation comments, keyed by the serialized citation. The comment @@ -44,6 +50,47 @@ export function clearAssistantCitationCommentDraft(key: string): void { drafts.delete(key); } +/** + * Writes a composer's unsaved comments onto their citations in the prompt, the + * way dismissing the popover would have. Called when a question or an approval + * borrows the prompt editor: its chips unmount without a dismissal, and + * without this they would come back uncommented and Send would leave the + * comment out. A draft the dismissal rules would not commit (over the length + * limit) stays a draft and is resumed when its popover is opened again. + */ +export function commitAssistantCitationCommentDrafts(prompt: string, scope: string): string { + const citations = collectAssistantCitations(prompt); + // Keys are read off the prompt as it is: committing one comment changes that + // citation's serialized form, which the later duplicates' ordinals count. + const keys = citations.map((entry, index) => + assistantCitationDraftKey( + entry.citation, + citations.slice(0, index).map((earlier) => earlier.citation), + scope, + ), + ); + let committed = prompt; + for (let index = citations.length - 1; index >= 0; index -= 1) { + const { citation, start, end } = citations[index]!; + const key = keys[index]!; + const draft = drafts.get(key); + if (draft === undefined) continue; + const dismissal = resolveAssistantCitationCommentDismissal({ + reason: "none", + draft, + savedComment: citation.comment, + }); + if (dismissal.kind === "keep-open") continue; + drafts.delete(key); + if (dismissal.kind !== "commit") continue; + committed = + committed.slice(0, start) + + serializeAssistantCitation(withAssistantCitationComment(citation, dismissal.comment)) + + committed.slice(end); + } + return committed; +} + export type AssistantCitationCommentDraftEntries = ReadonlyArray< readonly [key: string, draft: string] >; From b2d89dec08126ac710f647f6ea2fc0f7697f7cc0 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Thu, 1 Oct 2026 18:45:12 +0200 Subject: [PATCH 8/8] test(web): cover committing citation comment drafts into the prompt --- .../assistantCitationCommentDrafts.test.ts | 161 +++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts index f2684cb7adb8..1a9f7bd3bc55 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDrafts.test.ts @@ -1,8 +1,19 @@ -import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; +import { + ASSISTANT_CITATION_MAX_COMMENT_LENGTH, + EnvironmentId, + MessageId, + ThreadId, + type AssistantCitation, +} from "@t3tools/contracts"; +import { + collectAssistantCitations, + serializeAssistantCitation, +} from "@t3tools/shared/assistantCitations"; import { describe, expect, it } from "vite-plus/test"; import { assistantCitationDraftKey, + commitAssistantCitationCommentDrafts, readAssistantCitationCommentDraft, restoreAssistantCitationCommentDrafts, takeAssistantCitationCommentDraftsForComposer, @@ -45,6 +56,154 @@ describe("assistantCitationDraftKey", () => { }); }); +describe("commitAssistantCitationCommentDrafts", () => { + it("writes a trimmed draft onto its citation, preserves surrounding text, and removes the draft", () => { + const scope = "commit-new-comment"; + const key = assistantCitationDraftKey(citation, [], scope); + const before = "Before the quote.\n\n"; + const after = "\nAfter the quote. "; + const prompt = `${before}${serializeAssistantCitation(citation)}${after}`; + writeAssistantCitationCommentDraft(key, " \n Please explain this. \t "); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + const expected = { ...citation, comment: "Please explain this." }; + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([expected]); + expect(committed).toBe(`${before}${serializeAssistantCitation(expected)}${after}`); + expect(readAssistantCitationCommentDraft(key)).toBeNull(); + }); + + it("replaces a saved comment with a different draft and removes the draft", () => { + const scope = "commit-replacement-comment"; + const saved = { ...citation, comment: "Old comment" }; + const key = assistantCitationDraftKey(saved, [], scope); + const prompt = serializeAssistantCitation(saved); + writeAssistantCitationCommentDraft(key, "New comment"); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([ + { ...citation, comment: "New comment" }, + ]); + expect(readAssistantCitationCommentDraft(key)).toBeNull(); + }); + + it("leaves the prompt unchanged and removes a draft equal to the saved comment after trimming", () => { + const scope = "commit-unchanged-comment"; + const saved = { ...citation, comment: "Saved comment" }; + const key = assistantCitationDraftKey(saved, [], scope); + const prompt = serializeAssistantCitation(saved); + writeAssistantCitationCommentDraft(key, " \n Saved comment \t "); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(committed).toBe(prompt); + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([saved]); + expect(readAssistantCitationCommentDraft(key)).toBeNull(); + }); + + it("leaves the prompt unchanged and retains a draft over the comment length limit", () => { + const scope = "commit-over-limit-comment"; + const key = assistantCitationDraftKey(citation, [], scope); + const prompt = serializeAssistantCitation(citation); + const draft = "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1); + writeAssistantCitationCommentDraft(key, draft); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(committed).toBe(prompt); + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([citation]); + expect(readAssistantCitationCommentDraft(key)).toBe(draft); + }); + + it("commits different drafts for identical citations using their original ordinals", () => { + const scope = "commit-duplicate-citations"; + const first = assistantCitationDraftKey(citation, [], scope); + const second = assistantCitationDraftKey(citation, [citation], scope); + const link = serializeAssistantCitation(citation); + const prompt = `Before ${link}\nBetween ${link}\nAfter`; + writeAssistantCitationCommentDraft(first, "First comment"); + writeAssistantCitationCommentDraft(second, "Second comment"); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + const expectedFirst = { ...citation, comment: "First comment" }; + const expectedSecond = { ...citation, comment: "Second comment" }; + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([ + expectedFirst, + expectedSecond, + ]); + expect(committed).toBe( + `Before ${serializeAssistantCitation(expectedFirst)}\nBetween ${serializeAssistantCitation(expectedSecond)}\nAfter`, + ); + expect(readAssistantCitationCommentDraft(first)).toBeNull(); + expect(readAssistantCitationCommentDraft(second)).toBeNull(); + }); + + it("changes only the second of two different citations when only it has a draft", () => { + const scope = "commit-second-citation"; + const first = assistantCitationDraftKey(citation, [], scope); + const second = assistantCitationDraftKey(other, [citation], scope); + const firstLink = serializeAssistantCitation(citation); + const prompt = `${firstLink}\nBetween\n${serializeAssistantCitation(other)}`; + writeAssistantCitationCommentDraft(second, "Comment on world"); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + const expectedSecond = { ...other, comment: "Comment on world" }; + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([ + citation, + expectedSecond, + ]); + expect(committed).toBe(`${firstLink}\nBetween\n${serializeAssistantCitation(expectedSecond)}`); + expect(readAssistantCitationCommentDraft(first)).toBeNull(); + expect(readAssistantCitationCommentDraft(second)).toBeNull(); + }); + + it("neither applies nor removes drafts belonging to another scope", () => { + const scope = "commit-scope-isolation"; + const elsewhere = assistantCitationDraftKey(citation, [], "commit-scope-isolation-other"); + const prompt = serializeAssistantCitation(citation); + writeAssistantCitationCommentDraft(elsewhere, "Another composer's comment"); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(committed).toBe(prompt); + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([citation]); + expect(readAssistantCitationCommentDraft(elsewhere)).toBe("Another composer's comment"); + }); + + it("returns the same prompt when its citations have no drafts", () => { + const scope = "commit-no-drafts"; + const first = assistantCitationDraftKey(citation, [], scope); + const second = assistantCitationDraftKey(other, [citation], scope); + const prompt = `Before ${serializeAssistantCitation(citation)}\n${serializeAssistantCitation(other)} After`; + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(committed).toBe(prompt); + expect(collectAssistantCitations(committed).map((entry) => entry.citation)).toEqual([ + citation, + other, + ]); + expect(readAssistantCitationCommentDraft(first)).toBeNull(); + expect(readAssistantCitationCommentDraft(second)).toBeNull(); + }); + + it("returns the same prompt without citations and retains its scope's unrelated draft", () => { + const scope = "commit-no-citations"; + const key = assistantCitationDraftKey(citation, [], scope); + const prompt = "Plain prompt\n[ordinary link](https://example.com) "; + writeAssistantCitationCommentDraft(key, "Keep this draft"); + + const committed = commitAssistantCitationCommentDrafts(prompt, scope); + + expect(committed).toBe(prompt); + expect(collectAssistantCitations(committed)).toEqual([]); + expect(readAssistantCitationCommentDraft(key)).toBe("Keep this draft"); + }); +}); + describe("takeAssistantCitationCommentDraftsForComposer", () => { it("takes the sent composer's drafts, keeps every other composer's, and restores what it took", () => { const sent = assistantCitationDraftKey(citation, [], "thread-a");