| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | 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 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | 10 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; |
| 4 | import StarterKit from "@tiptap/starter-kit"; | 11 | import StarterKit from "@tiptap/starter-kit"; |
| 5 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; | 12 | import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| ... | @@ -31,6 +38,7 @@ import { | ... | @@ -31,6 +38,7 @@ import { |
| 31 | use, | 38 | use, |
| 32 | useCallback, | 39 | useCallback, |
| 33 | useEffect, | 40 | useEffect, |
| | 41 | useEffectEvent, |
| 34 | useImperativeHandle, | 42 | useImperativeHandle, |
| 35 | useLayoutEffect, | 43 | useLayoutEffect, |
| 36 | useMemo, | 44 | useMemo, |
| ... | @@ -107,6 +115,7 @@ import { | ... | @@ -107,6 +115,7 @@ import { |
| 107 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | 115 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 108 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 116 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 109 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; | 117 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; |
| | 118 | import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; |
| 110 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; | 119 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; |
| 111 | import { importPastedComposerText } from "./composerInlineTokenPaste"; | 120 | import { importPastedComposerText } from "./composerInlineTokenPaste"; |
| 112 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; | 121 | import { didComposerSelectionChangeVisibly } from "./composerSelection"; |
| ... | @@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps { | ... | @@ -180,6 +189,12 @@ export interface ComposerPromptEditorProps { |
| 180 | onPageScrollKeyUp?: (key: string) => void; | 189 | onPageScrollKeyUp?: (key: string) => void; |
| 181 | onPageScrollRelease?: () => void; | 190 | onPageScrollRelease?: () => void; |
| 182 | onCitationSubmitAndSend?: () => void; | 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 | onPaste: React.ClipboardEventHandler<HTMLElement>; | 198 | onPaste: React.ClipboardEventHandler<HTMLElement>; |
| 184 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; | 199 | editorRef: React.RefObject<ComposerPromptEditorHandle | null>; |
| 185 | } | 200 | } |
| ... | @@ -204,8 +219,14 @@ type OpenCitationComment = { | ... | @@ -204,8 +219,14 @@ type OpenCitationComment = { |
| 204 | const ComposerCitationCommentContext = createContext<{ | 219 | const ComposerCitationCommentContext = createContext<{ |
| 205 | openComment: OpenCitationComment | null; | 220 | openComment: OpenCitationComment | null; |
| 206 | onOpenChange: (citeKey: string, open: boolean) => void; | 221 | onOpenChange: (citeKey: string, open: boolean) => void; |
| | 222 | onDraftChange: (citeKey: string, comment: string) => void; |
| 207 | onSubmitAndSend: () => void; | 223 | onSubmitAndSend: () => void; |
| 208 | }>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); | 224 | }>({ |
| | 225 | openComment: null, |
| | 226 | onOpenChange: () => {}, |
| | 227 | onDraftChange: () => {}, |
| | 228 | onSubmitAndSend: () => {}, |
| | 229 | }); |
| 209 | | 230 | |
| 210 | const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); | 231 | const RichComposerSkillsContext = createContext<ReadonlyArray<ServerProviderSkill>>([]); |
| 211 | | 232 | |
| ... | @@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { | ... | @@ -472,6 +493,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { |
| 472 | }, | 493 | }, |
| 473 | ...(removeOnCancel ? { onCancel: onRemove } : {}), | 494 | ...(removeOnCancel ? { onCancel: onRemove } : {}), |
| 474 | onSave: onSaveComment, | 495 | onSave: onSaveComment, |
| | 496 | onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), |
| 475 | onSaveAndSend: (comment) => { | 497 | onSaveAndSend: (comment) => { |
| 476 | if (!onSaveComment(comment)) return false; | 498 | if (!onSaveComment(comment)) return false; |
| 477 | commentContext.onSubmitAndSend(); | 499 | commentContext.onSubmitAndSend(); |
| ... | @@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -843,6 +865,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 843 | onPageScrollKeyUp, | 865 | onPageScrollKeyUp, |
| 844 | onPageScrollRelease, | 866 | onPageScrollRelease, |
| 845 | onCitationSubmitAndSend, | 867 | onCitationSubmitAndSend, |
| | 868 | onCitationDraftSave, |
| 846 | onPaste, | 869 | onPaste, |
| 847 | editorRef, | 870 | editorRef, |
| 848 | } = props; | 871 | } = props; |
| ... | @@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -928,12 +951,15 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 928 | const hasAppliedControlledSelectionRef = useRef(false); | 951 | const hasAppliedControlledSelectionRef = useRef(false); |
| 929 | const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null); | 952 | const citationRequestRef = useRef<ComposerCitationCommentRequest | null>(null); |
| 930 | const [openCitation, setOpenCitation] = useState<OpenCitationComment | null>(null); | 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 | const [isEmpty, setIsEmpty] = useState(value.length === 0); | 956 | const [isEmpty, setIsEmpty] = useState(value.length === 0); |
| 932 | | 957 | |
| 933 | const citationCommentActions = useMemo( | 958 | const citationCommentActions = useMemo( |
| 934 | () => ({ | 959 | () => ({ |
| 935 | openComment: openCitation, | 960 | openComment: openCitation, |
| 936 | onOpenChange: (nodeKey: string, open: boolean) => { | 961 | onOpenChange: (nodeKey: string, open: boolean) => { |
| | 962 | if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null; |
| 937 | setOpenCitation((current) => { | 963 | setOpenCitation((current) => { |
| 938 | if (open) { | 964 | if (open) { |
| 939 | if (current?.key === nodeKey) return current; | 965 | if (current?.key === nodeKey) return current; |
| ... | @@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -942,6 +968,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 942 | return current?.key === nodeKey ? null : current; | 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 | onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), | 974 | onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), |
| 946 | }), | 975 | }), |
| 947 | [onCitationSubmitAndSend, openCitation], | 976 | [onCitationSubmitAndSend, openCitation], |
| ... | @@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1529,6 +1558,39 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1529 | return next; | 1558 | return next; |
| 1530 | }, [clampEditorCursor, editor, literalText]); | 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 | // Controlled value/cursor from the store (history recall, chip insertion…). | 1594 | // Controlled value/cursor from the store (history recall, chip insertion…). |
| 1533 | useLayoutEffect(() => { | 1595 | useLayoutEffect(() => { |
| 1534 | if (!editor) return; | 1596 | if (!editor) return; |
| ... | @@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1563,6 +1625,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1563 | const pendingCitation = | 1625 | const pendingCitation = |
| 1564 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; | 1626 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; |
| 1565 | if (previousSnapshot.value !== value) { | 1627 | if (previousSnapshot.value !== value) { |
| | 1628 | saveCitationDraft(editor, previousSnapshot.value); |
| 1566 | editor.commands.setContent( | 1629 | editor.commands.setContent( |
| 1567 | buildDocJson(value, skillLabelFor, { styling: richText, literalText }), | 1630 | buildDocJson(value, skillLabelFor, { styling: richText, literalText }), |
| 1568 | { | 1631 | { |