diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..5316c4164f179 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -1,10 +1,13 @@ -import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { + MAX_CITATION_HREF_LENGTH, + formatAssistantCitationHref, + parseAssistantCitationHref, serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; import { createRef, useState } from "react"; -import { expect, it } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -31,19 +34,23 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + sourceCitation = citation, + onSend = () => {}, }: { question: boolean; editorRef: React.RefObject; + sourceCitation?: AssistantCitation; + onSend?: () => void; }) { - const [prompt, setPrompt] = useState(promptBefore); + const [prompt, setPrompt] = useState(() => `See ${serializeAssistantCitation(sourceCitation)} `); const [answer, setAnswer] = useState(""); return ( <>

hello world

@@ -58,6 +65,7 @@ function Composer({ disabled={false} placeholder="" onChange={(next) => (question ? setAnswer(next) : setPrompt(next))} + onCitationSubmitAndSend={onSend} onCitationDraftSave={(replacedValue, savedValue) => { if (replacedValue === prompt) setPrompt(savedValue); }} @@ -97,3 +105,96 @@ it("keeps an unsaved citation comment when a question takes the composer", async ); await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); + +it.each([ + ["save", 100], + ["send", 100], + ["question", 100], + ["save", 0], +] as const)( + "rejects a comment exceeding the citation URL budget on %s (%i characters available)", + async (action, availableSpace) => { + const source = { ...citation, messageId: MessageId.make("界".repeat(17_750)) }; + const sourceCitation = { + ...source, + messageId: MessageId.make( + source.messageId + + "m".repeat( + MAX_CITATION_HREF_LENGTH - formatAssistantCitationHref(source).length - availableSpace, + ), + ), + }; + const oversizedComment = availableSpace === 0 ? "x" : "界".repeat(100); + const prompt = `See ${serializeAssistantCitation(sourceCitation)} `; + expect(parseAssistantCitationHref(formatAssistantCitationHref(sourceCitation))).toEqual( + sourceCitation, + ); + expect( + parseAssistantCitationHref( + formatAssistantCitationHref(withAssistantCitationComment(sourceCitation, oversizedComment)), + ), + ).toBeNull(); + + const editorRef = createRef(); + const onSend = vi.fn(); + await using screen = await render( + , + ); + const sourceAnchor = findAssistantCitationSourceAnchor(document, sourceCitation); + if (!sourceAnchor) throw new Error("Citation source did not resolve"); + await act(async () => + editorRef.current?.requestCitationComment({ + previousValue: "See ", + value: prompt, + citationStart: "See ".length, + sourceAnchor, + insertedSpaces: { before: false, after: true }, + }), + ); + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toBeVisible(); + await comment.fill(oversizedComment); + await expect.element(comment).toHaveAttribute("aria-invalid", "true"); + const error = page.getByText( + "The citation link is too long. Shorten the comment or quoted text.", + ); + await expect.element(error).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + + if (action === "question") { + await screen.rerender( + , + ); + } else { + await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); + } + await vi.waitFor(() => { + const savedPrompt = screen.container.querySelector("output")?.dataset.prompt; + expect( + savedPrompt === prompt, + "Rejected comments must leave the saved prompt unchanged", + ).toBe(true); + expect(onSend).not.toHaveBeenCalled(); + }); + if (action !== "question") { + await expect.element(comment).toHaveValue(oversizedComment); + const acceptedComment = availableSpace === 0 ? "" : "ok"; + await comment.fill(acceptedComment); + await expect.element(comment).not.toHaveAttribute("aria-invalid", "true"); + await expect.element(error).not.toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); + await vi.waitFor(() => { + expect(screen.container.querySelector("output")?.dataset.prompt).toBe( + `See ${serializeAssistantCitation(withAssistantCitationComment(sourceCitation, acceptedComment))} `, + ); + expect(onSend).toHaveBeenCalledTimes(action === "send" ? 1 : 0); + }); + } + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index fe78eaacbe850..9bfdd39873f49 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -30,6 +30,8 @@ import type { ServerProviderSkill, } from "@t3tools/contracts"; import { + formatAssistantCitationHref, + parseAssistantCitationHref, serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; @@ -406,6 +408,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { if (!current || current.type.name !== "composer-citation") return false; const currentCitation = current.attrs.citation as AssistantCitation; const next = withAssistantCitationComment(currentCitation, comment); + if (!parseAssistantCitationHref(formatAssistantCitationHref(next))) return false; const tr = editor.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, citation: next, @@ -1503,6 +1506,7 @@ function ComposerPromptEditorTiptapInner( }); if (dismissal.kind !== "commit") return; const next = withAssistantCitationComment(citation, dismissal.comment); + if (!parseAssistantCitationHref(formatAssistantCitationHref(next))) return; current.view.dispatch( current.state.tr .setNodeMarkup(pos, undefined, { diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 0a8357bb9baba..abf11b21af383 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,5 +1,10 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; -import { useState, type Ref } from "react"; +import { + formatAssistantCitationHref, + parseAssistantCitationHref, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { useId, useState, type Ref } from "react"; import { textDirection } from "~/textDirection"; import { Button } from "../ui/button"; @@ -20,12 +25,20 @@ export function AssistantCitationCommentEditor({ onDraftChange?: (comment: string) => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); + const errorId = useId(); const commentTooLong = comment.length > ASSISTANT_CITATION_MAX_COMMENT_LENGTH; + const commentError = commentTooLong + ? `Comments can contain up to ${ASSISTANT_CITATION_MAX_COMMENT_LENGTH.toLocaleString()} characters.` + : !parseAssistantCitationHref( + formatAssistantCitationHref(withAssistantCitationComment(citation, comment)), + ) + ? "The citation link is too long. Shorten the comment or quoted text." + : null; const submit = () => { - if (!commentTooLong) onSubmit(comment); + if (!commentError) onSubmit(comment); }; const submitAndSend = () => { - if (commentTooLong) return; + if (commentError) return; if (onSubmitAndSend) { onSubmitAndSend(comment); } else { @@ -49,7 +62,8 @@ export function AssistantCitationCommentEditor({ ref={inputRef} aria-label="Comment on selected text" aria-description="Enter to save the citation comment; Command/Ctrl+Enter to save and send; Shift+Enter for a new line." - aria-invalid={commentTooLong || undefined} + aria-invalid={!!commentError || undefined} + aria-errormessage={commentError ? errorId : undefined} placeholder="Add an optional comment..." rows={2} dir={textDirection(comment)} @@ -75,10 +89,9 @@ export function AssistantCitationCommentEditor({ } }} /> - {commentTooLong ? ( -

- Comments can contain up to {ASSISTANT_CITATION_MAX_COMMENT_LENGTH.toLocaleString()}{" "} - characters. + {commentError ? ( +

+ {commentError}

) : null}
@@ -92,7 +105,7 @@ export function AssistantCitationCommentEditor({