| author | |
| committer | |
| log | 0ff87f251dafb32703d5f531e7b248ad0a581ee2 |
| tree | 174cdf818bc5efec348733ebd6583098be0756d6 |
| parent | 4a560b4e4ebb37efb7f57805ba79e37f5500bdca |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { | |
| 2 | ASSISTANT_CITATION_MAX_COMMENT_LENGTH, | |
| 3 | EnvironmentId, | |
| 4 | MessageId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { act, useState, type ReactNode } from "react"; | |
| 8 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | |
| 12 | ||
| 13 | const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() })); | |
| 14 | vi.mock("./AssistantCitationSource", () => ({ | |
| 15 | observeAssistantCitationCommentSource: mocks.observeSource, | |
| 16 | })); | |
| 17 | vi.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. | |
| 22 | vi.mock("../ui/tooltip", () => ({ | |
| 23 | Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 24 | TooltipTrigger: ({ render }: { render: ReactNode }) => render, | |
| 25 | TooltipPopup: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 26 | })); | |
| 27 | vi.mock("../ui/popover", () => ({ | |
| 28 | Popover: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 29 | PopoverTrigger: ({ children }: { children: ReactNode }) => <button>{children}</button>, | |
| 30 | PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 31 | })); | |
| 32 | vi.mock("../ui/button", () => ({ | |
| 33 | Button: (props: React.ComponentProps<"button">) => <button {...props} />, | |
| 34 | })); | |
| 35 | ||
| 36 | import { PopoverPopup } from "../ui/popover"; | |
| 37 | import { AssistantCitationChip } from "./AssistantCitationChip"; | |
| 38 | ||
| 39 | const 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. | |
| 51 | const sourceAnchor = { | |
| 52 | source: {}, | |
| 53 | range: {}, | |
| 54 | viewport: {}, | |
| 55 | } as AssistantCitationSourceAnchor; | |
| 56 | ||
| 57 | let renderer: ReactTestRenderer; | |
| 58 | ||
| 59 | function 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 | ||
| 75 | function typeComment(value: string) { | |
| 76 | act(() => renderer.root.findByType("textarea").props.onChange({ currentTarget: { value } })); | |
| 77 | } | |
| 78 | ||
| 79 | function removeSource() { | |
| 80 | const { onUnavailable } = mocks.observeSource.mock.lastCall![0]; | |
| 81 | act(() => onUnavailable()); | |
| 82 | } | |
| 83 | ||
| 84 | function clickButton(label: string) { | |
| 85 | act(() => | |
| 86 | renderer.root | |
| 87 | .findAllByType("button") | |
| 88 | .find((button) => button.props.children === label)! | |
| 89 | .props.onClick(), | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | beforeEach(() => { | |
| 94 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 95 | mocks.observeSource.mockReset().mockReturnValue(mocks.dispose); | |
| 96 | mocks.dispose.mockClear(); | |
| 97 | }); | |
| 98 | ||
| 99 | afterEach(() => { | |
| 100 | act(() => renderer?.unmount()); | |
| 101 | vi.unstubAllGlobals(); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("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"; |
| 2 | 2 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; |
| 3 | 3 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | import { PencilIcon, QuoteIcon } from "lucide-react"; |
| 5 | import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react"; | |
| 5 | import { | |
| 6 | useEffect, | |
| 7 | useEffectEvent, | |
| 8 | useRef, | |
| 9 | useState, | |
| 10 | type MouseEvent as ReactMouseEvent, | |
| 11 | } from "react"; | |
| 6 | 12 | import { |
| 7 | 13 | findAssistantCitationSourceAnchor, |
| 8 | 14 | type AssistantCitationSourceAnchor, |
| ... | ... | @@ -23,6 +29,7 @@ import { |
| 23 | 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 24 | 30 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 25 | 31 | import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; |
| 32 | import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; | |
| 26 | 33 | import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; |
| 27 | 34 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 28 | 35 | |
| ... | ... | @@ -49,13 +56,35 @@ export function AssistantCitationChip({ |
| 49 | 56 | }) { |
| 50 | 57 | const navigate = useNavigate(); |
| 51 | 58 | const commentInputRef = useRef<HTMLTextAreaElement>(null); |
| 59 | const draftCommentRef = useRef<string | null>(null); | |
| 60 | const [unavailableSourceAnchor, setUnavailableSourceAnchor] = | |
| 61 | useState<AssistantCitationSourceAnchor | null>(null); | |
| 52 | 62 | const commentOpen = commentEditor?.open ?? false; |
| 53 | 63 | 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 | }; | |
| 54 | 77 | 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 | } | |
| 56 | 85 | }); |
| 57 | 86 | useEffect(() => { |
| 58 | if (!commentOpen) return; | |
| 87 | if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return; | |
| 59 | 88 | const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); |
| 60 | 89 | if (!anchor) return; |
| 61 | 90 | return observeAssistantCitationCommentSource({ |
| ... | ... | @@ -63,15 +92,15 @@ export function AssistantCitationChip({ |
| 63 | 92 | citation, |
| 64 | 93 | onUnavailable: onSourceUnavailable, |
| 65 | 94 | }); |
| 66 | }, [citation, commentOpen, sourceAnchor]); | |
| 95 | }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]); | |
| 67 | 96 | // A multi-line selection's bounding box spans the full message width; anchor |
| 68 | 97 | // the bubble to the selection's last line, where the pointer released. |
| 69 | const popupAnchor = sourceAnchor | |
| 98 | const popupAnchor = activeSourceAnchor | |
| 70 | 99 | ? { |
| 71 | contextElement: sourceAnchor.source, | |
| 100 | contextElement: activeSourceAnchor.source, | |
| 72 | 101 | 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(); | |
| 75 | 104 | }, |
| 76 | 105 | } |
| 77 | 106 | : undefined; |
| ... | ... | @@ -130,7 +159,16 @@ export function AssistantCitationChip({ |
| 130 | 159 | </Tooltip> |
| 131 | 160 | )} |
| 132 | 161 | {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 | > | |
| 134 | 172 | <PopoverTrigger |
| 135 | 173 | aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"} |
| 136 | 174 | className={CITATION_ACTION_BUTTON_CLASS_NAME} |
| ... | ... | @@ -140,7 +178,7 @@ export function AssistantCitationChip({ |
| 140 | 178 | {commentEditor.open ? ( |
| 141 | 179 | <PopoverPopup |
| 142 | 180 | {...composerFloatingLayerProps} |
| 143 | side={sourceAnchor ? "bottom" : "top"} | |
| 181 | side={activeSourceAnchor ? "bottom" : "top"} | |
| 144 | 182 | align="end" |
| 145 | 183 | anchor={popupAnchor} |
| 146 | 184 | initialFocus={() => { |
| ... | ... | @@ -156,6 +194,9 @@ export function AssistantCitationChip({ |
| 156 | 194 | key={serializeAssistantCitation(citation)} |
| 157 | 195 | citation={citation} |
| 158 | 196 | inputRef={commentInputRef} |
| 197 | onDraftChange={(comment) => { | |
| 198 | draftCommentRef.current = comment; | |
| 199 | }} | |
| 159 | 200 | onSubmit={(comment) => { |
| 160 | 201 | if (!commentEditor.onSave(comment)) return false; |
| 161 | 202 | commentEditor.onOpenChange(false); |
apps/web/src/components/chat/AssistantCitationCommentEditor.tsx+6-1| ... | ... | @@ -9,12 +9,14 @@ export function AssistantCitationCommentEditor({ |
| 9 | 9 | onSubmit, |
| 10 | 10 | onSubmitAndSend, |
| 11 | 11 | onCancel, |
| 12 | onDraftChange, | |
| 12 | 13 | }: { |
| 13 | 14 | citation: AssistantCitation; |
| 14 | 15 | inputRef?: Ref<HTMLTextAreaElement>; |
| 15 | 16 | onSubmit: (comment: string) => boolean; |
| 16 | 17 | onSubmitAndSend?: (comment: string) => boolean; |
| 17 | 18 | onCancel: () => void; |
| 19 | onDraftChange?: (comment: string) => void; | |
| 18 | 20 | }) { |
| 19 | 21 | const [comment, setComment] = useState(citation.comment ?? ""); |
| 20 | 22 | const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; |
| ... | ... | @@ -51,7 +53,10 @@ export function AssistantCitationCommentEditor({ |
| 51 | 53 | rows={2} |
| 52 | 54 | 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" |
| 53 | 55 | value={comment} |
| 54 | onChange={(event) => setComment(event.currentTarget.value)} | |
| 56 | onChange={(event) => { | |
| 57 | setComment(event.currentTarget.value); | |
| 58 | onDraftChange?.(event.currentTarget.value); | |
| 59 | }} | |
| 55 | 60 | onKeyDown={(event) => { |
| 56 | 61 | if ( |
| 57 | 62 | event.key === "Enter" && |
apps/web/src/components/chat/assistantCitationCommentDismissal.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; | |
| 5 | ||
| 6 | describe("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 @@ |
| 1 | import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export type AssistantCitationCommentDismissal = | |
| 4 | | { kind: "commit"; comment: string } | |
| 5 | | { kind: "close" } | |
| 6 | | { kind: "keep-open" }; | |
| 7 | ||
| 8 | export 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 | } |