From fc313180cfa8f367f274fa3c74ff2799c3e03bc7 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 8 Sep 2026 20:47:16 +0000 Subject: [PATCH 1/4] fix(web): keep citation comment when popover is dismissed Typing a comment in the citation popover and clicking elsewhere unmounted the editor and dropped the text, while the chip stayed in the prompt. Dismissal now commits the draft; Escape and Cancel still discard, and an over-length draft keeps the popover open so the error stays visible. Written by Claude Fable 5.1 via Claude Code. --- .../components/chat/AssistantCitationChip.tsx | 35 ++++++++- .../chat/AssistantCitationCommentEditor.tsx | 8 +- .../assistantCitationCommentDismissal.test.ts | 73 +++++++++++++++++++ .../chat/assistantCitationCommentDismissal.ts | 27 +++++++ 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts create mode 100644 apps/web/src/components/chat/assistantCitationCommentDismissal.ts diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index ccfd746666a4..4c5635d5b539 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -22,6 +22,7 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; +import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; @@ -48,10 +49,28 @@ export function AssistantCitationChip({ }) { const navigate = useNavigate(); const commentInputRef = useRef(null); + // Unsaved comment text, held outside the editor so a dismissal can commit it + // after Base UI has already decided to close the popover. + const draftCommentRef = useRef(null); const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; + useEffect(() => { + if (!commentOpen) draftCommentRef.current = null; + }, [commentOpen]); + // Saves the draft if it can be saved. Returns false when the popover must stay open. + const settleDraftOnClose = (reason: string): boolean => { + const dismissal = resolveAssistantCitationCommentDismissal({ + reason, + draft: draftCommentRef.current, + savedComment: citation.comment, + }); + if (dismissal.kind === "commit") commentEditor?.onSave(dismissal.comment); + return dismissal.kind !== "keep-open"; + }; const onSourceUnavailable = useEffectEvent(() => { - if (sourceAnchor) commentEditor?.onOpenChange(false); + if (!sourceAnchor) return; + settleDraftOnClose("none"); + commentEditor?.onOpenChange(false); }); useEffect(() => { if (!commentOpen) return; @@ -129,7 +148,16 @@ export function AssistantCitationChip({ )} {commentEditor ? ( - + { + if (!open && !settleDraftOnClose(eventDetails.reason)) { + eventDetails.cancel(); + return; + } + commentEditor.onOpenChange(open); + }} + > { + draftCommentRef.current = comment; + }} onSubmit={(comment) => { if (!commentEditor.onSave(comment)) return false; commentEditor.onOpenChange(false); diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 4dc422210de0..0f57db582e4d 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -9,12 +9,15 @@ export function AssistantCitationCommentEditor({ onSubmit, onSubmitAndSend, onCancel, + onDraftChange, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; + /** Reports every keystroke so the owner can commit the draft when the popover is dismissed. */ + onDraftChange?: (comment: string) => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; @@ -51,7 +54,10 @@ export function AssistantCitationCommentEditor({ rows={2} className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm" value={comment} - onChange={(event) => setComment(event.currentTarget.value)} + onChange={(event) => { + setComment(event.currentTarget.value); + onDraftChange?.(event.currentTarget.value); + }} onKeyDown={(event) => { if ( event.key === "Enter" && diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts new file mode 100644 index 000000000000..b82a6cb2d02a --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts @@ -0,0 +1,73 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; + +describe("resolveAssistantCitationCommentDismissal", () => { + it("commits typed text when the popover is dismissed by clicking away", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "needs a retry", + savedComment: undefined, + }), + ).toEqual({ kind: "commit", comment: "needs a retry" }); + }); + + it("commits an edited comment when focus leaves the popover", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "focus-out", + draft: "second thought", + savedComment: "first thought", + }), + ).toEqual({ kind: "commit", comment: "second thought" }); + }); + + it("closes without saving when nothing changed", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: null, + savedComment: "kept", + }), + ).toEqual({ kind: "close" }); + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: " kept ", + savedComment: "kept", + }), + ).toEqual({ kind: "close" }); + }); + + it("clears a comment when the draft was emptied", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "trigger-press", + draft: "", + savedComment: "old", + }), + ).toEqual({ kind: "commit", comment: "" }); + }); + + it("keeps Escape as an explicit discard", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "escape-key", + draft: "unsaved", + savedComment: undefined, + }), + ).toEqual({ kind: "close" }); + }); + + it("keeps the popover open instead of dropping an over-length draft", () => { + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1), + savedComment: undefined, + }), + ).toEqual({ kind: "keep-open" }); + }); +}); diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts new file mode 100644 index 000000000000..1e722392c0a8 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts @@ -0,0 +1,27 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; + +export type AssistantCitationCommentDismissal = + | { kind: "commit"; comment: string } + | { kind: "close" } + | { kind: "keep-open" }; + +/** + * Decides what happens to unsaved comment text when the citation popover closes + * without Save or Cancel: clicking away, focus leaving, or toggling the pencil. + * Typed text is committed rather than dropped. Escape stays an explicit discard, + * and a draft over the length limit keeps the popover open so the error is visible. + */ +export function resolveAssistantCitationCommentDismissal({ + reason, + draft, + savedComment, +}: { + reason: string; + draft: string | null; + savedComment: string | undefined; +}): AssistantCitationCommentDismissal { + if (reason === "escape-key" || draft === null) return { kind: "close" }; + if (draft.trim() === (savedComment ?? "")) return { kind: "close" }; + if (draft.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH) return { kind: "keep-open" }; + return { kind: "commit", comment: draft }; +} From b1296bff03ac506840a02371e187f4b85d4e7245 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 8 Sep 2026 20:51:57 +0000 Subject: [PATCH 2/4] fix(web): keep citation popover open when dismissal save is refused Written by Claude Fable 5.1 via Claude Code. --- apps/web/src/components/chat/AssistantCitationChip.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4c5635d5b539..5dc900103f18 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -57,16 +57,19 @@ export function AssistantCitationChip({ useEffect(() => { if (!commentOpen) draftCommentRef.current = null; }, [commentOpen]); - // Saves the draft if it can be saved. Returns false when the popover must stay open. + // Saves the draft if it can be saved. Returns false when the popover must stay + // open, either because the draft is too long or the composer refused the save. const settleDraftOnClose = (reason: string): boolean => { const dismissal = resolveAssistantCitationCommentDismissal({ reason, draft: draftCommentRef.current, savedComment: citation.comment, }); - if (dismissal.kind === "commit") commentEditor?.onSave(dismissal.comment); + if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true; return dismissal.kind !== "keep-open"; }; + // The popup is positioned against the source range, so it cannot stay open + // once that range is gone. Save what can be saved and close regardless. const onSourceUnavailable = useEffectEvent(() => { if (!sourceAnchor) return; settleDraftOnClose("none"); From 042fa48541f414faa47170dd6260fa7b2f97582a Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 8 Sep 2026 21:04:51 +0000 Subject: [PATCH 3/4] fix(web): treat whitespace-only comment differences as unchanged Written by Claude Fable 5.1 via Claude Code. --- .../chat/assistantCitationCommentDismissal.test.ts | 7 +++++++ .../components/chat/assistantCitationCommentDismissal.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts index b82a6cb2d02a..1f21677a5a82 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts @@ -39,6 +39,13 @@ describe("resolveAssistantCitationCommentDismissal", () => { savedComment: "kept", }), ).toEqual({ kind: "close" }); + expect( + resolveAssistantCitationCommentDismissal({ + reason: "outside-press", + draft: "kept", + savedComment: " kept ", + }), + ).toEqual({ kind: "close" }); }); it("clears a comment when the draft was emptied", () => { diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts index 1e722392c0a8..49f3d2d1a9ce 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts @@ -21,7 +21,7 @@ export function resolveAssistantCitationCommentDismissal({ savedComment: string | undefined; }): AssistantCitationCommentDismissal { if (reason === "escape-key" || draft === null) return { kind: "close" }; - if (draft.trim() === (savedComment ?? "")) return { kind: "close" }; + if (draft.trim() === (savedComment ?? "").trim()) return { kind: "close" }; if (draft.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH) return { kind: "keep-open" }; return { kind: "commit", comment: draft }; } From 0e77c4f2b450b5f9a040f2972ee4bef151725296 Mon Sep 17 00:00:00 2001 From: flamboh Date: Tue, 8 Sep 2026 21:18:49 +0000 Subject: [PATCH 4/4] chore(web): drop citation dismissal comments Written by Claude Fable 5.1 via Claude Code. --- apps/web/src/components/chat/AssistantCitationChip.tsx | 6 ------ .../src/components/chat/AssistantCitationCommentEditor.tsx | 1 - .../components/chat/assistantCitationCommentDismissal.ts | 6 ------ 3 files changed, 13 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 5dc900103f18..9b1469c9b00d 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -49,16 +49,12 @@ export function AssistantCitationChip({ }) { const navigate = useNavigate(); const commentInputRef = useRef(null); - // Unsaved comment text, held outside the editor so a dismissal can commit it - // after Base UI has already decided to close the popover. const draftCommentRef = useRef(null); const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; useEffect(() => { if (!commentOpen) draftCommentRef.current = null; }, [commentOpen]); - // Saves the draft if it can be saved. Returns false when the popover must stay - // open, either because the draft is too long or the composer refused the save. const settleDraftOnClose = (reason: string): boolean => { const dismissal = resolveAssistantCitationCommentDismissal({ reason, @@ -68,8 +64,6 @@ export function AssistantCitationChip({ if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true; return dismissal.kind !== "keep-open"; }; - // The popup is positioned against the source range, so it cannot stay open - // once that range is gone. Save what can be saved and close regardless. const onSourceUnavailable = useEffectEvent(() => { if (!sourceAnchor) return; settleDraftOnClose("none"); diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 0f57db582e4d..d9fd60645618 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -16,7 +16,6 @@ export function AssistantCitationCommentEditor({ onSubmit: (comment: string) => boolean; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; - /** Reports every keystroke so the owner can commit the draft when the popover is dismissed. */ onDraftChange?: (comment: string) => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); diff --git a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts index 49f3d2d1a9ce..a6fe2ca3bcb6 100644 --- a/apps/web/src/components/chat/assistantCitationCommentDismissal.ts +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts @@ -5,12 +5,6 @@ export type AssistantCitationCommentDismissal = | { kind: "close" } | { kind: "keep-open" }; -/** - * Decides what happens to unsaved comment text when the citation popover closes - * without Save or Cancel: clicking away, focus leaving, or toggling the pencil. - * Typed text is committed rather than dropped. Escape stays an explicit discard, - * and a draft over the length limit keeps the popover open so the error is visible. - */ export function resolveAssistantCitationCommentDismissal({ reason, draft,