diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index ccfd746666a4..9b1469c9b00d 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,25 @@ export function AssistantCitationChip({ }) { const navigate = useNavigate(); const commentInputRef = useRef(null); + const draftCommentRef = useRef(null); const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; + useEffect(() => { + if (!commentOpen) draftCommentRef.current = null; + }, [commentOpen]); + 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"; + }; const onSourceUnavailable = useEffectEvent(() => { - if (sourceAnchor) commentEditor?.onOpenChange(false); + if (!sourceAnchor) return; + settleDraftOnClose("none"); + commentEditor?.onOpenChange(false); }); useEffect(() => { if (!commentOpen) return; @@ -129,7 +145,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..d9fd60645618 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -9,12 +9,14 @@ export function AssistantCitationCommentEditor({ onSubmit, onSubmitAndSend, onCancel, + onDraftChange, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; + onDraftChange?: (comment: string) => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; @@ -51,7 +53,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..1f21677a5a82 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts @@ -0,0 +1,80 @@ +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" }); + 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..a6fe2ca3bcb6 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationCommentDismissal.ts @@ -0,0 +1,21 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; + +export type AssistantCitationCommentDismissal = + | { kind: "commit"; comment: string } + | { kind: "close" } + | { kind: "keep-open" }; + +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 ?? "").trim()) return { kind: "close" }; + if (draft.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH) return { kind: "keep-open" }; + return { kind: "commit", comment: draft }; +}