| author | |
| committer | |
| log | 9fb4ddb2d1ccad4452b15c2934170da275cb8bc6 |
| tree | e4a4fd058a60c2e403cfdc2027e1685042cfba89 |
| parent | 52408973888c87bc314e51e338c79687b1ebf59d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>4 files changed, 202 insertions(+), 2 deletions(-)
apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.test.tsx created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | ||
| 3 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | serializeAssistantCitation, | |
| 6 | withAssistantCitationComment, | |
| 7 | } from "@t3tools/shared/assistantCitations"; | |
| 8 | import { act, createRef, useState, type ReactNode } from "react"; | |
| 9 | import { createRoot, type Root } from "react-dom/client"; | |
| 10 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | |
| 13 | ||
| 14 | vi.mock("./chat/AssistantCitationSource", () => ({ | |
| 15 | observeAssistantCitationCommentSource: () => () => {}, | |
| 16 | })); | |
| 17 | vi.mock("@tanstack/react-router", () => ({ | |
| 18 | useNavigate: () => () => {}, | |
| 19 | Link: ({ children }: { children: ReactNode }) => <a>{children}</a>, | |
| 20 | })); | |
| 21 | // Keep the real chip and comment editor while replacing floating layers. | |
| 22 | vi.mock("./ui/popover", () => ({ | |
| 23 | Popover: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 24 | PopoverTrigger: ({ "aria-label": label }: { "aria-label": string }) => ( | |
| 25 | <button aria-label={label} /> | |
| 26 | ), | |
| 27 | PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 28 | PopoverClose: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 29 | })); | |
| 30 | ||
| 31 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | |
| 32 | ||
| 33 | const citation = { | |
| 34 | version: 1 as const, | |
| 35 | environmentId: EnvironmentId.make("environment"), | |
| 36 | threadId: ThreadId.make("thread"), | |
| 37 | messageId: MessageId.make("source"), | |
| 38 | text: "hello", | |
| 39 | start: 0, | |
| 40 | end: 5, | |
| 41 | prefix: "", | |
| 42 | suffix: "", | |
| 43 | }; | |
| 44 | const promptBefore = `See ${serializeAssistantCitation(citation)} `; | |
| 45 | ||
| 46 | let root: Root; | |
| 47 | let container: HTMLDivElement; | |
| 48 | ||
| 49 | beforeEach(() => { | |
| 50 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 51 | container = document.createElement("div"); | |
| 52 | document.body.append(container); | |
| 53 | root = createRoot(container); | |
| 54 | }); | |
| 55 | ||
| 56 | afterEach(async () => { | |
| 57 | await act(async () => root.unmount()); | |
| 58 | container.remove(); | |
| 59 | vi.unstubAllGlobals(); | |
| 60 | }); | |
| 61 | ||
| 62 | // Mirrors ChatComposer: a pending question shows its answer in the same editor. | |
| 63 | function Composer({ | |
| 64 | question, | |
| 65 | editorRef, | |
| 66 | }: { | |
| 67 | question: boolean; | |
| 68 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; | |
| 69 | }) { | |
| 70 | const [prompt, setPrompt] = useState(promptBefore); | |
| 71 | const [answer, setAnswer] = useState(""); | |
| 72 | return ( | |
| 73 | <> | |
| 74 | <output data-prompt={prompt} data-answer={answer} /> | |
| 75 | <ComposerPromptEditor | |
| 76 | editorRef={editorRef} | |
| 77 | value={question ? answer : prompt} | |
| 78 | cursor={0} | |
| 79 | contextRecords={new Map()} | |
| 80 | skills={[]} | |
| 81 | disabled={false} | |
| 82 | placeholder="" | |
| 83 | onChange={(next) => (question ? setAnswer(next) : setPrompt(next))} | |
| 84 | onCitationDraftSave={(replacedValue, savedValue) => { | |
| 85 | if (replacedValue === prompt) setPrompt(savedValue); | |
| 86 | }} | |
| 87 | onPaste={() => {}} | |
| 88 | /> | |
| 89 | </> | |
| 90 | ); | |
| 91 | } | |
| 92 | ||
| 93 | it("keeps an unsaved citation comment when a question takes the composer", async () => { | |
| 94 | const editorRef = createRef<ComposerPromptEditorHandle>(); | |
| 95 | const drafts = () => container.querySelector("output")?.dataset; | |
| 96 | ||
| 97 | await act(async () => root.render(<Composer question={false} editorRef={editorRef} />)); | |
| 98 | await act(async () => | |
| 99 | editorRef.current?.requestCitationComment({ | |
| 100 | previousValue: "See ", | |
| 101 | value: promptBefore, | |
| 102 | citationStart: "See ".length, | |
| 103 | sourceAnchor: {} as AssistantCitationSourceAnchor, | |
| 104 | insertedSpaces: { before: false, after: true }, | |
| 105 | }), | |
| 106 | ); | |
| 107 | const textarea = container.querySelector("textarea"); | |
| 108 | if (!textarea) throw new Error("Comment editor did not open"); | |
| 109 | await act(async () => { | |
| 110 | const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; | |
| 111 | setValue?.call(textarea, "check this"); | |
| 112 | textarea.dispatchEvent(new Event("input", { bubbles: true })); | |
| 113 | }); | |
| 114 | ||
| 115 | await act(async () => root.render(<Composer question editorRef={editorRef} />)); | |
| 116 | expect(drafts()?.answer).toBe(""); | |
| 117 | await act(async () => root.render(<Composer question={false} editorRef={editorRef} />)); | |
| 118 | ||
| 119 | expect(drafts()?.prompt).toBe( | |
| 120 | `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `, | |
| 121 | ); | |
| 122 | expect(container.querySelector('[aria-label="Edit citation comment"]')).not.toBeNull(); | |
| 123 | }); |
apps/web/src/components/ComposerPromptEditorTiptap.tsx+65-2| ... | ... | @@ -1,5 +1,12 @@ |
| 1 | 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 2 | import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; | |
| 2 | import { | |
| 3 | Extension, | |
| 4 | findChildren, | |
| 5 | InputRule, | |
| 6 | Node, | |
| 7 | wrappingInputRule, | |
| 8 | type JSONContent, | |
| 9 | } from "@tiptap/core"; | |
| 3 | 10 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; |
| 4 | 11 | import StarterKit from "@tiptap/starter-kit"; |
| 5 | 12 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| ... | ... | @@ -31,6 +38,7 @@ import { |
| 31 | 38 | use, |
| 32 | 39 | useCallback, |
| 33 | 40 | useEffect, |
| 41 | useEffectEvent, | |
| 34 | 42 | useImperativeHandle, |
| 35 | 43 | useLayoutEffect, |
| 36 | 44 | useMemo, |
| ... | ... | @@ -107,6 +115,7 @@ import { |
| 107 | 115 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 108 | 116 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 109 | 117 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; |
| 118 | import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; | |
| 110 | 119 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; |
| 111 | 120 | import { importPastedComposerText } from "./composerInlineTokenPaste"; |
| 112 | 121 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; |
| ... | ... | @@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps { |
| 180 | 189 | onPageScrollKeyUp?: (key: string) => void; |
| 181 | 190 | onPageScrollRelease?: () => void; |
| 182 | 191 | onCitationSubmitAndSend?: () => void; |
| 192 | /** | |
| 193 | * Called when a new controlled value replaces the document while a citation | |
| 194 | * comment is unsaved. The comment is saved into `replacedValue`, giving | |
| 195 | * `savedValue`, so the owner of `replacedValue` can keep it. | |
| 196 | */ | |
| 197 | onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; | |
| 183 | 198 | onPaste: React.ClipboardEventHandler<HTMLElement>; |
| 184 | 199 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; |
| 185 | 200 | } |
| ... | ... | @@ -204,8 +219,14 @@ type OpenCitationComment = { |
| 204 | 219 | const ComposerCitationCommentContext = createContext<{ |
| 205 | 220 | openComment: OpenCitationComment | null; |
| 206 | 221 | onOpenChange: (citeKey: string, open: boolean) => void; |
| 222 | onDraftChange: (citeKey: string, comment: string) => void; | |
| 207 | 223 | onSubmitAndSend: () => void; |
| 208 | }>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); | |
| 224 | }>({ | |
| 225 | openComment: null, | |
| 226 | onOpenChange: () => {}, | |
| 227 | onDraftChange: () => {}, | |
| 228 | onSubmitAndSend: () => {}, | |
| 229 | }); | |
| 209 | 230 | |
| 210 | 231 | const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); |
| 211 | 232 | |
| ... | ... | @@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { |
| 472 | 493 | }, |
| 473 | 494 | ...(removeOnCancel ? { onCancel: onRemove } : {}), |
| 474 | 495 | onSave: onSaveComment, |
| 496 | onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), | |
| 475 | 497 | onSaveAndSend: (comment) => { |
| 476 | 498 | if (!onSaveComment(comment)) return false; |
| 477 | 499 | commentContext.onSubmitAndSend(); |
| ... | ... | @@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 843 | 865 | onPageScrollKeyUp, |
| 844 | 866 | onPageScrollRelease, |
| 845 | 867 | onCitationSubmitAndSend, |
| 868 | onCitationDraftSave, | |
| 846 | 869 | onPaste, |
| 847 | 870 | editorRef, |
| 848 | 871 | } = props; |
| ... | ... | @@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 928 | 951 | const hasAppliedControlledSelectionRef = useRef(false); |
| 929 | 952 | const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null); |
| 930 | 953 | const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null); |
| 954 | // The open citation's unsaved comment, saved before a new value replaces the document. | |
| 955 | const citationDraftRef = useRef<{ key: string; comment: string } | null>(null); | |
| 931 | 956 | const [isEmpty, setIsEmpty] = useState(value.length === 0); |
| 932 | 957 | |
| 933 | 958 | const citationCommentActions = useMemo( |
| 934 | 959 | () => ({ |
| 935 | 960 | openComment: openCitation, |
| 936 | 961 | onOpenChange: (nodeKey: string, open: boolean) => { |
| 962 | if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null; | |
| 937 | 963 | setOpenCitation((current) => { |
| 938 | 964 | if (open) { |
| 939 | 965 | if (current?.key === nodeKey) return current; |
| ... | ... | @@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 942 | 968 | return current?.key === nodeKey ? null : current; |
| 943 | 969 | }); |
| 944 | 970 | }, |
| 971 | onDraftChange: (nodeKey: string, comment: string) => { | |
| 972 | citationDraftRef.current = { key: nodeKey, comment }; | |
| 973 | }, | |
| 945 | 974 | onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), |
| 946 | 975 | }), |
| 947 | 976 | [onCitationSubmitAndSend, openCitation], |
| ... | ... | @@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1529 | 1558 | return next; |
| 1530 | 1559 | }, [clampEditorCursor, editor, literalText]); |
| 1531 | 1560 | |
| 1561 | // A question or approval swaps the controlled value while a citation comment | |
| 1562 | // is open. Its node view unmounts with the old document, so save the comment | |
| 1563 | // the way dismissing the popover would, and hand the saved value back. | |
| 1564 | const saveCitationDraft = useEffectEvent((current: TiptapEditor, replacedValue: string) => { | |
| 1565 | const draft = citationDraftRef.current; | |
| 1566 | citationDraftRef.current = null; | |
| 1567 | if (!draft || !onCitationDraftSave) return; | |
| 1568 | const [target] = findChildren( | |
| 1569 | current.state.doc, | |
| 1570 | (node) => node.type.name === "composer-citation" && node.attrs.citeKey === draft.key, | |
| 1571 | ); | |
| 1572 | if (!target) return; | |
| 1573 | const { node, pos } = target; | |
| 1574 | const citation = node.attrs.citation as AssistantCitation; | |
| 1575 | const dismissal = resolveAssistantCitationCommentDismissal({ | |
| 1576 | reason: "none", | |
| 1577 | draft: draft.comment, | |
| 1578 | savedComment: citation.comment, | |
| 1579 | }); | |
| 1580 | if (dismissal.kind !== "commit") return; | |
| 1581 | const next = withAssistantCitationComment(citation, dismissal.comment); | |
| 1582 | current.view.dispatch( | |
| 1583 | current.state.tr | |
| 1584 | .setNodeMarkup(pos, undefined, { | |
| 1585 | ...node.attrs, | |
| 1586 | citation: next, | |
| 1587 | source: serializeAssistantCitation(next), | |
| 1588 | }) | |
| 1589 | .setMeta("addToHistory", false), | |
| 1590 | ); | |
| 1591 | onCitationDraftSave(replacedValue, serializeEditorDoc(current.state.doc).value); | |
| 1592 | }); | |
| 1593 | ||
| 1532 | 1594 | // Controlled value/cursor from the store (history recall, chip insertion…). |
| 1533 | 1595 | useLayoutEffect(() => { |
| 1534 | 1596 | if (!editor) return; |
| ... | ... | @@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1563 | 1625 | const pendingCitation = |
| 1564 | 1626 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; |
| 1565 | 1627 | if (previousSnapshot.value !== value) { |
| 1628 | saveCitationDraft(editor, previousSnapshot.value); | |
| 1566 | 1629 | editor.commands.setContent( |
| 1567 | 1630 | buildDocJson(value, skillLabelFor, { styling: richText, literalText }), |
| 1568 | 1631 | { |
apps/web/src/components/chat/AssistantCitationChip.tsx+3| ... | ... | @@ -47,6 +47,8 @@ export function AssistantCitationChip({ |
| 47 | 47 | onCancel?: () => void; |
| 48 | 48 | onSave: (comment: string) => boolean; |
| 49 | 49 | onSaveAndSend?: (comment: string) => boolean; |
| 50 | /** Reports each keystroke of the unsaved comment. */ | |
| 51 | onDraftChange?: (comment: string) => void; | |
| 50 | 52 | /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ |
| 51 | 53 | onRestoreFocus?: () => void; |
| 52 | 54 | }; |
| ... | ... | @@ -211,6 +213,7 @@ export function AssistantCitationChip({ |
| 211 | 213 | inputRef={commentInputRef} |
| 212 | 214 | onDraftChange={(comment) => { |
| 213 | 215 | draftCommentRef.current = comment; |
| 216 | commentEditor.onDraftChange?.(comment); | |
| 214 | 217 | }} |
| 215 | 218 | onSubmit={(comment) => { |
| 216 | 219 | if (!commentEditor.onSave(comment)) return false; |
apps/web/src/components/chat/ChatComposer.tsx+11| ... | ... | @@ -4358,6 +4358,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4358 | 4358 | }), |
| 4359 | 4359 | ); |
| 4360 | 4360 | }, [phase, settings.followUpBehavior, submitComposer]); |
| 4361 | // A question or approval took the editor while a citation comment was | |
| 4362 | // unsaved. Keep the comment in the prompt that comes back afterward. | |
| 4363 | const saveCitationDraftToPrompt = useCallback( | |
| 4364 | (replacedValue: string, savedValue: string) => { | |
| 4365 | if (replacedValue !== prompt) return; | |
| 4366 | if (promptRef.current === replacedValue) promptRef.current = savedValue; | |
| 4367 | setPrompt(savedValue); | |
| 4368 | }, | |
| 4369 | [prompt, promptRef, setPrompt], | |
| 4370 | ); | |
| 4361 | 4371 | const compactThreadContext = useCallback(() => { |
| 4362 | 4372 | if ( |
| 4363 | 4373 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || |
| ... | ... | @@ -7591,6 +7601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7591 | 7601 | onPageScrollKeyUp={onPageScrollKeyUp} |
| 7592 | 7602 | onPageScrollRelease={onPageScrollRelease} |
| 7593 | 7603 | onCitationSubmitAndSend={submitCitationAndSend} |
| 7604 | onCitationDraftSave={saveCitationDraftToPrompt} | |
| 7594 | 7605 | onPaste={onComposerPaste} |
| 7595 | 7606 | placeholder={ |
| 7596 | 7607 | isComposerApprovalState |