diff --git a/apps/web/src/components/chat/AssistantCitationChip.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.test.tsx
new file mode 100644
index 00000000000..a4b63b240b0
--- /dev/null
+++ b/apps/web/src/components/chat/AssistantCitationChip.test.tsx
@@ -0,0 +1,152 @@
+import {
+ ASSISTANT_CITATION_MAX_COMMENT_LENGTH,
+ EnvironmentId,
+ MessageId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { act, useState, type ReactNode } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
+
+const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() }));
+vi.mock("./AssistantCitationSource", () => ({
+ observeAssistantCitationCommentSource: mocks.observeSource,
+}));
+vi.mock("@tanstack/react-router", () => ({
+ useNavigate: () => vi.fn(),
+ Link: ({ children }: { children: ReactNode }) => {children},
+}));
+// Keep the real chip/editor lifecycle while replacing DOM positioning and portals.
+vi.mock("../ui/popover", () => ({
+ Popover: ({ children }: { children: ReactNode }) => <>{children}>,
+ PopoverTrigger: ({ children }: { children: ReactNode }) => ,
+ PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+vi.mock("../ui/button", () => ({
+ Button: (props: React.ComponentProps<"button">) => ,
+}));
+
+import { PopoverPopup } from "../ui/popover";
+import { AssistantCitationChip } from "./AssistantCitationChip";
+
+const citation = {
+ version: 1 as const,
+ environmentId: EnvironmentId.make("environment"),
+ threadId: ThreadId.make("thread"),
+ messageId: MessageId.make("source"),
+ text: "hello",
+ start: 0,
+ end: 5,
+ prefix: "",
+ suffix: "",
+};
+// The observer owns DOM access; these identities let us check which anchor survives.
+const sourceAnchor = {
+ source: {},
+ range: {},
+ viewport: {},
+} as AssistantCitationSourceAnchor;
+
+let renderer: ReactTestRenderer;
+
+function mount(onSave = vi.fn(() => true)) {
+ function Composer() {
+ const [open, setOpen] = useState(true);
+ return (
+ {}}
+ commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
+ />
+ );
+ }
+ act(() => {
+ renderer = create();
+ });
+ return onSave;
+}
+
+function typeComment(value: string) {
+ act(() => renderer.root.findByType("textarea").props.onChange({ currentTarget: { value } }));
+}
+
+function removeSource() {
+ const { onUnavailable } = mocks.observeSource.mock.lastCall![0];
+ act(() => onUnavailable());
+}
+
+function clickButton(label: string) {
+ act(() =>
+ renderer.root
+ .findAllByType("button")
+ .find((button) => button.props.children === label)!
+ .props.onClick(),
+ );
+}
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ mocks.observeSource.mockReset().mockReturnValue(mocks.dispose);
+ mocks.dispose.mockClear();
+});
+
+afterEach(() => {
+ act(() => renderer?.unmount());
+ vi.unstubAllGlobals();
+});
+
+describe("citation comment source disappearance", () => {
+ it("preserves an over-length draft at the composer until it can be shortened and saved", () => {
+ const onSave = mount();
+ const draft = "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1);
+ typeComment(draft);
+ removeSource();
+
+ expect(renderer.root.findByType("textarea").props.value).toBe(draft);
+ expect(renderer.root.findByType("textarea").props["aria-invalid"]).toBe(true);
+ expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
+ expect(renderer.root.findByType(PopoverPopup).props.side).toBe("top");
+ expect(onSave).not.toHaveBeenCalled();
+ expect(mocks.dispose).toHaveBeenCalled();
+ expect(mocks.observeSource).toHaveBeenCalledTimes(1);
+
+ typeComment("shortened comment");
+ clickButton("Save");
+ expect(onSave).toHaveBeenCalledWith("shortened comment");
+ expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
+ });
+
+ it("preserves a rejected save and allows a later retry", () => {
+ const onSave = mount(vi.fn(() => false));
+ typeComment("keep this draft");
+ removeSource();
+
+ expect(onSave).toHaveBeenCalledWith("keep this draft");
+ expect(renderer.root.findByType("textarea").props.value).toBe("keep this draft");
+ expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
+ onSave.mockReturnValue(true);
+ clickButton("Save");
+ expect(onSave).toHaveBeenLastCalledWith("keep this draft");
+ expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
+ });
+
+ it("still allows explicit cancellation after source disappearance", () => {
+ const onSave = mount(vi.fn(() => false));
+ typeComment("discard this draft");
+ removeSource();
+ onSave.mockClear();
+ clickButton("Cancel");
+ expect(onSave).not.toHaveBeenCalled();
+ expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
+ });
+
+ it("saves and closes when the source disappears with a valid draft", () => {
+ const onSave = mount();
+ typeComment("saved comment");
+ removeSource();
+ expect(onSave).toHaveBeenCalledWith("saved comment");
+ expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx
index ccfd746666a..ef68691c5ae 100644
--- a/apps/web/src/components/chat/AssistantCitationChip.tsx
+++ b/apps/web/src/components/chat/AssistantCitationChip.tsx
@@ -2,7 +2,13 @@ import type { AssistantCitation } from "@t3tools/contracts";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import { Link, useNavigate } from "@tanstack/react-router";
import { PencilIcon, QuoteIcon, XIcon } from "lucide-react";
-import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react";
+import {
+ useEffect,
+ useEffectEvent,
+ useRef,
+ useState,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
import {
findAssistantCitationSourceAnchor,
type AssistantCitationSourceAnchor,
@@ -22,6 +28,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,13 +55,35 @@ export function AssistantCitationChip({
}) {
const navigate = useNavigate();
const commentInputRef = useRef(null);
+ const draftCommentRef = useRef(null);
+ const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
+ useState(null);
const commentOpen = commentEditor?.open ?? false;
const sourceAnchor = commentEditor?.sourceAnchor;
+ const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : 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;
+ if (settleDraftOnClose("none")) {
+ commentEditor?.onOpenChange(false);
+ } else {
+ // Keep the draft mounted, positioned at the composer trigger instead of a detached range.
+ setUnavailableSourceAnchor(sourceAnchor);
+ }
});
useEffect(() => {
- if (!commentOpen) return;
+ if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return;
const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation);
if (!anchor) return;
return observeAssistantCitationCommentSource({
@@ -62,15 +91,15 @@ export function AssistantCitationChip({
citation,
onUnavailable: onSourceUnavailable,
});
- }, [citation, commentOpen, sourceAnchor]);
+ }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]);
// A multi-line selection's bounding box spans the full message width; anchor
// the bubble to the selection's last line, where the pointer released.
- const popupAnchor = sourceAnchor
+ const popupAnchor = activeSourceAnchor
? {
- contextElement: sourceAnchor.source,
+ contextElement: activeSourceAnchor.source,
getBoundingClientRect: () => {
- const rects = sourceAnchor.range.getClientRects();
- return rects.item(rects.length - 1) ?? sourceAnchor.range.getBoundingClientRect();
+ const rects = activeSourceAnchor.range.getClientRects();
+ return rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect();
},
}
: undefined;
@@ -129,7 +158,16 @@ export function AssistantCitationChip({
)}
{commentEditor ? (
-
+ {
+ if (!open && !settleDraftOnClose(eventDetails.reason)) {
+ eventDetails.cancel();
+ return;
+ }
+ commentEditor.onOpenChange(open);
+ }}
+ >
{
@@ -155,6 +193,9 @@ export function AssistantCitationChip({
key={serializeAssistantCitation(citation)}
citation={citation}
inputRef={commentInputRef}
+ onDraftChange={(comment) => {
+ 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 4dc422210de..d9fd6064561 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 00000000000..1f21677a5a8
--- /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 00000000000..a6fe2ca3bcb
--- /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 };
+}