authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-20 05:55:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 18:25:11+05:30
log0ff87f251dafb32703d5f531e7b248ad0a581ee2
tree174cdf818bc5efec348733ebd6583098be0756d6
parent4a560b4e4ebb37efb7f57805ba79e37f5500bdca
signature Signed by PGP key B5690EEEBB952194

fix(web): keep citation comment when popover is dismissed (#10831)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

5 files changed, 314 insertions(+), 11 deletions(-)

apps/web/src/components/chat/AssistantCitationChip.test.tsx created+156
......@@ -0,0 +1,156 @@
1import {
2 ASSISTANT_CITATION_MAX_COMMENT_LENGTH,
3 EnvironmentId,
4 MessageId,
5 ThreadId,
6} from "@t3tools/contracts";
7import { act, useState, type ReactNode } from "react";
8import { create, type ReactTestRenderer } from "react-test-renderer";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
12
13const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() }));
14vi.mock("./AssistantCitationSource", () => ({
15 observeAssistantCitationCommentSource: mocks.observeSource,
16}));
17vi.mock("@tanstack/react-router", () => ({
18 useNavigate: () => vi.fn(),
19 Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
20}));
21// Keep the real chip/editor lifecycle while replacing DOM positioning and floating layers.
22vi.mock("../ui/tooltip", () => ({
23 Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
24 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
25 TooltipPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
26}));
27vi.mock("../ui/popover", () => ({
28 Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
29 PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>,
30 PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
31}));
32vi.mock("../ui/button", () => ({
33 Button: (props: React.ComponentProps<"button">) => <button {...props} />,
34}));
35
36import { PopoverPopup } from "../ui/popover";
37import { AssistantCitationChip } from "./AssistantCitationChip";
38
39const citation = {
40 version: 1 as const,
41 environmentId: EnvironmentId.make("environment"),
42 threadId: ThreadId.make("thread"),
43 messageId: MessageId.make("source"),
44 text: "hello",
45 start: 0,
46 end: 5,
47 prefix: "",
48 suffix: "",
49};
50// The observer owns DOM access; these identities let us check which anchor survives.
51const sourceAnchor = {
52 source: {},
53 range: {},
54 viewport: {},
55} as AssistantCitationSourceAnchor;
56
57let renderer: ReactTestRenderer;
58
59function mount(onSave = vi.fn(() => true)) {
60 function Composer() {
61 const [open, setOpen] = useState(true);
62 return (
63 <AssistantCitationChip
64 citation={citation}
65 commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
66 />
67 );
68 }
69 act(() => {
70 renderer = create(<Composer />);
71 });
72 return onSave;
73}
74
75function typeComment(value: string) {
76 act(() => renderer.root.findByType("textarea").props.onChange({ currentTarget: { value } }));
77}
78
79function removeSource() {
80 const { onUnavailable } = mocks.observeSource.mock.lastCall![0];
81 act(() => onUnavailable());
82}
83
84function clickButton(label: string) {
85 act(() =>
86 renderer.root
87 .findAllByType("button")
88 .find((button) => button.props.children === label)!
89 .props.onClick(),
90 );
91}
92
93beforeEach(() => {
94 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
95 mocks.observeSource.mockReset().mockReturnValue(mocks.dispose);
96 mocks.dispose.mockClear();
97});
98
99afterEach(() => {
100 act(() => renderer?.unmount());
101 vi.unstubAllGlobals();
102});
103
104describe("citation comment source disappearance", () => {
105 it("preserves an over-length draft at the composer until it can be shortened and saved", () => {
106 const onSave = mount();
107 const draft = "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1);
108 typeComment(draft);
109 removeSource();
110
111 expect(renderer.root.findByType("textarea").props.value).toBe(draft);
112 expect(renderer.root.findByType("textarea").props["aria-invalid"]).toBe(true);
113 expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
114 expect(renderer.root.findByType(PopoverPopup).props.side).toBe("top");
115 expect(onSave).not.toHaveBeenCalled();
116 expect(mocks.dispose).toHaveBeenCalled();
117 expect(mocks.observeSource).toHaveBeenCalledTimes(1);
118
119 typeComment("shortened comment");
120 clickButton("Save");
121 expect(onSave).toHaveBeenCalledWith("shortened comment");
122 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
123 });
124
125 it("preserves a rejected save and allows a later retry", () => {
126 const onSave = mount(vi.fn(() => false));
127 typeComment("keep this draft");
128 removeSource();
129
130 expect(onSave).toHaveBeenCalledWith("keep this draft");
131 expect(renderer.root.findByType("textarea").props.value).toBe("keep this draft");
132 expect(renderer.root.findByType(PopoverPopup).props.anchor).toBeUndefined();
133 onSave.mockReturnValue(true);
134 clickButton("Save");
135 expect(onSave).toHaveBeenLastCalledWith("keep this draft");
136 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
137 });
138
139 it("still allows explicit cancellation after source disappearance", () => {
140 const onSave = mount(vi.fn(() => false));
141 typeComment("discard this draft");
142 removeSource();
143 onSave.mockClear();
144 clickButton("Cancel");
145 expect(onSave).not.toHaveBeenCalled();
146 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
147 });
148
149 it("saves and closes when the source disappears with a valid draft", () => {
150 const onSave = mount();
151 typeComment("saved comment");
152 removeSource();
153 expect(onSave).toHaveBeenCalledWith("saved comment");
154 expect(renderer.root.findAllByType("textarea")).toHaveLength(0);
155 });
156});
apps/web/src/components/chat/AssistantCitationChip.tsx+51-10
......@@ -2,7 +2,13 @@ import type { AssistantCitation } from "@t3tools/contracts";
22import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
33import { Link, useNavigate } from "@tanstack/react-router";
44import { PencilIcon, QuoteIcon } from "lucide-react";
5import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react";
5import {
6 useEffect,
7 useEffectEvent,
8 useRef,
9 useState,
10 type MouseEvent as ReactMouseEvent,
11} from "react";
612import {
713 findAssistantCitationSourceAnchor,
814 type AssistantCitationSourceAnchor,
......@@ -23,6 +29,7 @@ import {
2329import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2430import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
2531import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
32import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
2633import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
2734import { composerFloatingLayerProps } from "./composerEventScope";
2835
......@@ -49,13 +56,35 @@ export function AssistantCitationChip({
4956}) {
5057 const navigate = useNavigate();
5158 const commentInputRef = useRef<HTMLTextAreaElement>(null);
59 const draftCommentRef = useRef<string | null>(null);
60 const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
61 useState<AssistantCitationSourceAnchor | null>(null);
5262 const commentOpen = commentEditor?.open ?? false;
5363 const sourceAnchor = commentEditor?.sourceAnchor;
64 const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor;
65 useEffect(() => {
66 if (!commentOpen) draftCommentRef.current = null;
67 }, [commentOpen]);
68 const settleDraftOnClose = (reason: string): boolean => {
69 const dismissal = resolveAssistantCitationCommentDismissal({
70 reason,
71 draft: draftCommentRef.current,
72 savedComment: citation.comment,
73 });
74 if (dismissal.kind === "commit") return commentEditor?.onSave(dismissal.comment) ?? true;
75 return dismissal.kind !== "keep-open";
76 };
5477 const onSourceUnavailable = useEffectEvent(() => {
55 if (sourceAnchor) commentEditor?.onOpenChange(false);
78 if (!sourceAnchor) return;
79 if (settleDraftOnClose("none")) {
80 commentEditor?.onOpenChange(false);
81 } else {
82 // Keep the draft mounted, positioned at the composer trigger instead of a detached range.
83 setUnavailableSourceAnchor(sourceAnchor);
84 }
5685 });
5786 useEffect(() => {
58 if (!commentOpen) return;
87 if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return;
5988 const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation);
6089 if (!anchor) return;
6190 return observeAssistantCitationCommentSource({
......@@ -63,15 +92,15 @@ export function AssistantCitationChip({
6392 citation,
6493 onUnavailable: onSourceUnavailable,
6594 });
66 }, [citation, commentOpen, sourceAnchor]);
95 }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]);
6796 // A multi-line selection's bounding box spans the full message width; anchor
6897 // the bubble to the selection's last line, where the pointer released.
69 const popupAnchor = sourceAnchor
98 const popupAnchor = activeSourceAnchor
7099 ? {
71 contextElement: sourceAnchor.source,
100 contextElement: activeSourceAnchor.source,
72101 getBoundingClientRect: () => {
73 const rects = sourceAnchor.range.getClientRects();
74 return rects.item(rects.length - 1) ?? sourceAnchor.range.getBoundingClientRect();
102 const rects = activeSourceAnchor.range.getClientRects();
103 return rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect();
75104 },
76105 }
77106 : undefined;
......@@ -130,7 +159,16 @@ export function AssistantCitationChip({
130159 </Tooltip>
131160 )}
132161 {commentEditor ? (
133 <Popover open={commentEditor.open} onOpenChange={commentEditor.onOpenChange}>
162 <Popover
163 open={commentEditor.open}
164 onOpenChange={(open, eventDetails) => {
165 if (!open && !settleDraftOnClose(eventDetails.reason)) {
166 eventDetails.cancel();
167 return;
168 }
169 commentEditor.onOpenChange(open);
170 }}
171 >
134172 <PopoverTrigger
135173 aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}
136174 className={CITATION_ACTION_BUTTON_CLASS_NAME}
......@@ -140,7 +178,7 @@ export function AssistantCitationChip({
140178 {commentEditor.open ? (
141179 <PopoverPopup
142180 {...composerFloatingLayerProps}
143 side={sourceAnchor ? "bottom" : "top"}
181 side={activeSourceAnchor ? "bottom" : "top"}
144182 align="end"
145183 anchor={popupAnchor}
146184 initialFocus={() => {
......@@ -156,6 +194,9 @@ export function AssistantCitationChip({
156194 key={serializeAssistantCitation(citation)}
157195 citation={citation}
158196 inputRef={commentInputRef}
197 onDraftChange={(comment) => {
198 draftCommentRef.current = comment;
199 }}
159200 onSubmit={(comment) => {
160201 if (!commentEditor.onSave(comment)) return false;
161202 commentEditor.onOpenChange(false);
apps/web/src/components/chat/AssistantCitationCommentEditor.tsx+6-1
......@@ -9,12 +9,14 @@ export function AssistantCitationCommentEditor({
99 onSubmit,
1010 onSubmitAndSend,
1111 onCancel,
12 onDraftChange,
1213}: {
1314 citation: AssistantCitation;
1415 inputRef?: Ref<HTMLTextAreaElement>;
1516 onSubmit: (comment: string) => boolean;
1617 onSubmitAndSend?: (comment: string) => boolean;
1718 onCancel: () => void;
19 onDraftChange?: (comment: string) => void;
1820}) {
1921 const [comment, setComment] = useState(citation.comment ?? "");
2022 const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH;
......@@ -51,7 +53,10 @@ export function AssistantCitationCommentEditor({
5153 rows={2}
5254 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"
5355 value={comment}
54 onChange={(event) => setComment(event.currentTarget.value)}
56 onChange={(event) => {
57 setComment(event.currentTarget.value);
58 onDraftChange?.(event.currentTarget.value);
59 }}
5560 onKeyDown={(event) => {
5661 if (
5762 event.key === "Enter" &&
apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts created+80
......@@ -0,0 +1,80 @@
1import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
5
6describe("resolveAssistantCitationCommentDismissal", () => {
7 it("commits typed text when the popover is dismissed by clicking away", () => {
8 expect(
9 resolveAssistantCitationCommentDismissal({
10 reason: "outside-press",
11 draft: "needs a retry",
12 savedComment: undefined,
13 }),
14 ).toEqual({ kind: "commit", comment: "needs a retry" });
15 });
16
17 it("commits an edited comment when focus leaves the popover", () => {
18 expect(
19 resolveAssistantCitationCommentDismissal({
20 reason: "focus-out",
21 draft: "second thought",
22 savedComment: "first thought",
23 }),
24 ).toEqual({ kind: "commit", comment: "second thought" });
25 });
26
27 it("closes without saving when nothing changed", () => {
28 expect(
29 resolveAssistantCitationCommentDismissal({
30 reason: "outside-press",
31 draft: null,
32 savedComment: "kept",
33 }),
34 ).toEqual({ kind: "close" });
35 expect(
36 resolveAssistantCitationCommentDismissal({
37 reason: "outside-press",
38 draft: " kept ",
39 savedComment: "kept",
40 }),
41 ).toEqual({ kind: "close" });
42 expect(
43 resolveAssistantCitationCommentDismissal({
44 reason: "outside-press",
45 draft: "kept",
46 savedComment: " kept ",
47 }),
48 ).toEqual({ kind: "close" });
49 });
50
51 it("clears a comment when the draft was emptied", () => {
52 expect(
53 resolveAssistantCitationCommentDismissal({
54 reason: "trigger-press",
55 draft: "",
56 savedComment: "old",
57 }),
58 ).toEqual({ kind: "commit", comment: "" });
59 });
60
61 it("keeps Escape as an explicit discard", () => {
62 expect(
63 resolveAssistantCitationCommentDismissal({
64 reason: "escape-key",
65 draft: "unsaved",
66 savedComment: undefined,
67 }),
68 ).toEqual({ kind: "close" });
69 });
70
71 it("keeps the popover open instead of dropping an over-length draft", () => {
72 expect(
73 resolveAssistantCitationCommentDismissal({
74 reason: "outside-press",
75 draft: "x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1),
76 savedComment: undefined,
77 }),
78 ).toEqual({ kind: "keep-open" });
79 });
80});
apps/web/src/components/chat/assistantCitationCommentDismissal.ts created+21
......@@ -0,0 +1,21 @@
1import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts";
2
3export type AssistantCitationCommentDismissal =
4 | { kind: "commit"; comment: string }
5 | { kind: "close" }
6 | { kind: "keep-open" };
7
8export function resolveAssistantCitationCommentDismissal({
9 reason,
10 draft,
11 savedComment,
12}: {
13 reason: string;
14 draft: string | null;
15 savedComment: string | undefined;
16}): AssistantCitationCommentDismissal {
17 if (reason === "escape-key" || draft === null) return { kind: "close" };
18 if (draft.trim() === (savedComment ?? "").trim()) return { kind: "close" };
19 if (draft.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH) return { kind: "keep-open" };
20 return { kind: "commit", comment: draft };
21}