From 94f574add42daa08678af6f6d8795d9ae970e1a4 Mon Sep 17 00:00:00 2001 From: Arham Mahajan Date: Sat, 31 Oct 2026 16:00:16 +0530 Subject: [PATCH 1/6] fix(shared): cite assistant messages with long IDs --- .../chat/AssistantSelectionToolbar.tsx | 24 ++++++--- packages/contracts/src/assistantCitations.ts | 4 +- .../shared/src/assistantCitations.test.ts | 49 +++++++++++++------ packages/shared/src/assistantCitations.ts | 3 +- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 6eed862ed7a03..a8ca39320f641 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -4,6 +4,10 @@ import { type AssistantCitation, type ScopedThreadRef, } from "@t3tools/contracts"; +import { + formatAssistantCitationHref, + parseAssistantCitationHref, +} from "@t3tools/shared/assistantCitations"; import { QuoteIcon } from "lucide-react"; import { use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -111,6 +115,19 @@ export function AssistantSelectionToolbar({ clear(); return; } + const citation = { + version: 1, + ...threadRef, + messageId: MessageId.make(messageId), + ...captured.selector, + } satisfies AssistantCitation; + if ( + citation.text.length <= ASSISTANT_CITATION_MAX_TEXT_LENGTH && + !parseAssistantCitationHref(formatAssistantCitationHref(citation)) + ) { + clear(); + return; + } const rects = captured.range.getClientRects(); const pointerOnText = pointer !== null && @@ -123,12 +140,7 @@ export function AssistantSelectionToolbar({ ); setSelection({ sourceAnchor: { source: captured.source, range: captured.range, viewport }, - citation: { - version: 1, - ...threadRef, - messageId: MessageId.make(messageId), - ...captured.selector, - }, + citation, position: resolveSelectionActionPosition({ bounds: viewportRect, selectionRect: rects.item(rects.length - 1) ?? rect, diff --git a/packages/contracts/src/assistantCitations.ts b/packages/contracts/src/assistantCitations.ts index 71934d1684ab0..76a7e889d7501 100644 --- a/packages/contracts/src/assistantCitations.ts +++ b/packages/contracts/src/assistantCitations.ts @@ -9,8 +9,8 @@ export const ASSISTANT_CITATION_CONTEXT_LENGTH = 32; export const AssistantCitation = Schema.Struct({ version: Schema.Literal(1), environmentId: EnvironmentId.check(Schema.isMaxLength(512)), - threadId: ThreadId.check(Schema.isMaxLength(512)), - messageId: MessageId.check(Schema.isMaxLength(512)), + threadId: ThreadId, + messageId: MessageId, text: Schema.String.check( Schema.isNonEmpty(), Schema.isMaxLength(ASSISTANT_CITATION_MAX_TEXT_LENGTH), diff --git a/packages/shared/src/assistantCitations.test.ts b/packages/shared/src/assistantCitations.test.ts index 923ca2555a0be..8a616eee0858e 100644 --- a/packages/shared/src/assistantCitations.test.ts +++ b/packages/shared/src/assistantCitations.test.ts @@ -59,19 +59,30 @@ describe("assistant citation references", () => { expect(serializeAssistantCitation(legacyCitation)).toBe(`[Assistant quote](${legacyHref})`); }); - it("round-trips complete quote data without a server origin", () => { - const href = formatAssistantCitationHref(citation); - expect(parseAssistantCitationHref(href)).toEqual(citation); - expect(href).toMatch( - /^t3-citation:\/\/v1\/environment%2Fremote\/thread%3Aone\/assistant%3Fone\?/, - ); - expect(href).not.toContain("localhost"); - const marker = serializeAssistantCitation(citation); - const prompt = `About ${marker}, explain this.`; - expect(collectAssistantCitations(prompt)).toEqual([ - { citation, source: marker, start: 6, end: 6 + marker.length }, - ]); - }); + it.each([ + [citation.threadId, citation.messageId], + [citation.threadId, MessageId.make("m".repeat(512))], + [citation.threadId, MessageId.make("m".repeat(513))], + [citation.threadId, MessageId.make("m".repeat(565))], + [ThreadId.make("thread:".repeat(100)), MessageId.make("provider-turn%3A".repeat(46))], + ] as const)( + "round-trips complete quote data without a server origin (%#)", + (threadId, messageId) => { + const source = { ...citation, threadId, messageId }; + const href = formatAssistantCitationHref(source); + expect(parseAssistantCitationHref(href)).toEqual(source); + expect(href).toMatch(/^t3-citation:\/\/v1\/environment%2Fremote\//); + expect(href).not.toContain("localhost"); + const marker = serializeAssistantCitation(source); + const prompt = `About ${marker}, explain this.`; + expect(collectAssistantCitations(prompt)).toEqual([ + { citation: source, source: marker, start: 6, end: 6 + marker.length }, + ]); + expect(readProviderContext(expandAssistantCitationsForProvider(prompt))).toEqual([ + { id: "assistant-quote-1", citation: source }, + ]); + }, + ); it.each([ ' Please keep "日本語 🚀", `cache[key]` & (a + b).\n\tWhy? #1 / 100%\r\n ', @@ -103,7 +114,7 @@ describe("assistant citation references", () => { expect(renderAssistantCitationsAsText(prompt)).toBe(prompt); }); - it("bounds selected text and surrounding context", () => { + it("bounds citation URLs, selected text and surrounding context", () => { expect( parseAssistantCitationHref( formatAssistantCitationHref({ @@ -120,9 +131,17 @@ describe("assistant citation references", () => { expect( parseAssistantCitationHref(formatAssistantCitationHref({ ...citation, text: " " })), ).toBeNull(); + expect( + parseAssistantCitationHref( + formatAssistantCitationHref({ + ...citation, + messageId: MessageId.make("m".repeat(160_000)), + }), + ), + ).toBeNull(); }); - it("accepts complete 8k CJK quotes and comments with maximum-sized source selectors", () => { + it("accepts complete 8k CJK quotes and comments with 512-character source IDs", () => { const largeCitation: AssistantCitation = { ...citation, environmentId: EnvironmentId.make("環".repeat(512)), diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index 9af6a29706724..c31a21c95137d 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -8,7 +8,8 @@ import * as Schema from "effect/Schema"; const CITATION_PROTOCOL = "t3-citation:"; const CITATION_HREF_PREFIX = `${CITATION_PROTOCOL}//v1/`; -// Percent encoding needs up to nine characters per UTF-16 code unit; 16k covers selectors. +// Percent encoding needs up to nine characters per UTF-16 code unit. +// The extra 16k is a shared budget for IDs, selectors, and URL syntax. const MAX_CITATION_HREF_LENGTH = 9 * (ASSISTANT_CITATION_MAX_TEXT_LENGTH + ASSISTANT_CITATION_MAX_COMMENT_LENGTH) + 16_000; const CITATION_LINK = new RegExp( From a81b449f6733cf5601283a342f6b0a8c7325c88d Mon Sep 17 00:00:00 2001 From: Arham Mahajan Date: Sat, 31 Oct 2026 16:23:24 +0530 Subject: [PATCH 2/6] fix(web): validate citations after editing comments --- ...ditorTiptap.citationDraft.browser.test.tsx | 83 +++++++++++++++++-- .../components/ComposerPromptEditorTiptap.tsx | 4 + 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..7b5d1c57705c9 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -1,10 +1,12 @@ -import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { + 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 +33,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 +64,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 +104,67 @@ 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", "send", "question"] as const)( + "rejects a comment exceeding the complete citation URL budget on %s", + async (action) => { + const sourceCitation = { ...citation, messageId: MessageId.make("界".repeat(17_750)) }; + const oversizedComment = "界".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); + + if (action === "question") { + await screen.rerender( + , + ); + } else { + await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); + } + 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); + await comment.fill("ok"); + await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); + expect(screen.container.querySelector("output")?.dataset.prompt).toBe( + `See ${serializeAssistantCitation(withAssistantCitationComment(sourceCitation, "ok"))} `, + ); + expect(onSend).toHaveBeenCalledTimes(action === "send" ? 1 : 0); + } + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index abc9549ee3d00..8ae184c852bea 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"; @@ -404,6 +406,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, @@ -1498,6 +1501,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, { From efe33fb0b5baf79c93732fbac10cb18cc6165a87 Mon Sep 17 00:00:00 2001 From: Arham Mahajan Date: Sat, 31 Oct 2026 16:26:58 +0530 Subject: [PATCH 3/6] fix(web): navigate citations up to the URL limit --- .../lib/assistantCitationNavigation.test.ts | 69 ++++++++++++++----- .../src/lib/assistantCitationNavigation.ts | 6 +- packages/shared/src/assistantCitations.ts | 2 +- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/apps/web/src/lib/assistantCitationNavigation.test.ts b/apps/web/src/lib/assistantCitationNavigation.test.ts index 8df1371ee348e..cd6580e72c9bf 100644 --- a/apps/web/src/lib/assistantCitationNavigation.test.ts +++ b/apps/web/src/lib/assistantCitationNavigation.test.ts @@ -1,4 +1,16 @@ -import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; +import { + ASSISTANT_CITATION_MAX_COMMENT_LENGTH, + ASSISTANT_CITATION_MAX_TEXT_LENGTH, + EnvironmentId, + MessageId, + ThreadId, + type AssistantCitation, +} from "@t3tools/contracts"; +import { + MAX_CITATION_HREF_LENGTH, + formatAssistantCitationHref, + parseAssistantCitationHref, +} from "@t3tools/shared/assistantCitations"; import { createMemoryHistory, createRootRoute, @@ -24,6 +36,21 @@ const citation: AssistantCitation = { suffix: "\nAfter.", }; +const largeCitation = { + ...citation, + text: "引".repeat(ASSISTANT_CITATION_MAX_TEXT_LENGTH), + comment: "注".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH), + start: 0, + end: ASSISTANT_CITATION_MAX_TEXT_LENGTH, +}; +const maximumCitation = { + ...largeCitation, + messageId: MessageId.make( + largeCitation.messageId + + "m".repeat(MAX_CITATION_HREF_LENGTH - formatAssistantCitationHref(largeCitation).length), + ), +}; + function createCitationRouter(initialEntry = "/environment-one/thread-one") { const root = createRootRoute(); const thread = createRoute({ getParentRoute: () => root, path: "/$environmentId/$threadId" }); @@ -34,25 +61,29 @@ function createCitationRouter(initialEntry = "/environment-one/thread-one") { } describe("assistant citation navigation", () => { - it("preserves encoded quote whitespace through router navigation and reload", async () => { - const router = createCitationRouter(); - await router.navigate(assistantCitationNavigation(citation)); + it.each([citation, maximumCitation])( + "preserves encoded quotes through router navigation and reload (%#)", + async (citation) => { + expect(parseAssistantCitationHref(formatAssistantCitationHref(citation))).toEqual(citation); + const router = createCitationRouter(); + await router.navigate(assistantCitationNavigation(citation)); - expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation); - let href = router.history.location.href; - for (let reload = 0; reload < 3; reload++) { - const reloaded = createCitationRouter(href); - await reloaded.load(); - await reloaded.navigate({ - to: "/$environmentId/$threadId", - params: { environmentId: citation.environmentId, threadId: citation.threadId }, - hash: true, - replace: true, - }); - href = reloaded.history.location.href; - expect(assistantCitationFromLocation(href)).toEqual(citation); - } - }); + expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation); + let href = router.history.location.href; + for (let reload = 0; reload < 3; reload++) { + const reloaded = createCitationRouter(href); + await reloaded.load(); + await reloaded.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: citation.environmentId, threadId: citation.threadId }, + hash: true, + replace: true, + }); + href = reloaded.history.location.href; + expect(assistantCitationFromLocation(href)).toEqual(citation); + } + }, + ); it("reactivates an identical citation instead of deduplicating its navigation", async () => { const router = createCitationRouter(); diff --git a/apps/web/src/lib/assistantCitationNavigation.ts b/apps/web/src/lib/assistantCitationNavigation.ts index 1320b73aa33c5..abfa3a5787e2f 100644 --- a/apps/web/src/lib/assistantCitationNavigation.ts +++ b/apps/web/src/lib/assistantCitationNavigation.ts @@ -1,6 +1,7 @@ import { pipe } from "effect/Function"; import type { AssistantCitation } from "@t3tools/contracts"; import { + MAX_CITATION_HREF_LENGTH, formatAssistantCitationHref, parseAssistantCitationHref, } from "@t3tools/shared/assistantCitations"; @@ -16,6 +17,9 @@ declare module "@tanstack/react-router" { } const CITATION_HASH_PREFIX = "assistant-citation="; +// Formatted citation URLs are ASCII, so byte and character lengths match. +const MAX_CITATION_HASH_LENGTH = + CITATION_HASH_PREFIX.length + 4 * Math.ceil(MAX_CITATION_HREF_LENGTH / 3); /** Base64url keeps router hash normalization from decoding quote whitespace or source IDs. */ export function assistantCitationHash(citation: AssistantCitation) { @@ -26,7 +30,7 @@ export function assistantCitationFromLocation(href: string) { const hashIndex = href.indexOf("#"); if (hashIndex === -1) return null; const hash = href.slice(hashIndex + 1); - if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null; + if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > MAX_CITATION_HASH_LENGTH) return null; try { return pipe( hash.slice(CITATION_HASH_PREFIX.length), diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index c31a21c95137d..c08472f3998b0 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -10,7 +10,7 @@ const CITATION_PROTOCOL = "t3-citation:"; const CITATION_HREF_PREFIX = `${CITATION_PROTOCOL}//v1/`; // Percent encoding needs up to nine characters per UTF-16 code unit. // The extra 16k is a shared budget for IDs, selectors, and URL syntax. -const MAX_CITATION_HREF_LENGTH = +export const MAX_CITATION_HREF_LENGTH = 9 * (ASSISTANT_CITATION_MAX_TEXT_LENGTH + ASSISTANT_CITATION_MAX_COMMENT_LENGTH) + 16_000; const CITATION_LINK = new RegExp( String.raw`\[Assistant quote\]\((${CITATION_HREF_PREFIX}[^\s)]{1,${MAX_CITATION_HREF_LENGTH - CITATION_HREF_PREFIX.length}})\)`, From fe752f53e039da8ea6715135092429c6aaad49ed Mon Sep 17 00:00:00 2001 From: Arham Mahajan Date: Sat, 31 Oct 2026 18:29:31 +0530 Subject: [PATCH 4/6] test(web): wait for citation comment updates --- ...ditorTiptap.citationDraft.browser.test.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 7b5d1c57705c9..4b4fda185ee49 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -152,19 +152,24 @@ it.each(["save", "send", "question"] as const)( } else { await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); } - 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(); + 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); await comment.fill("ok"); await userEvent.keyboard(action === "send" ? "{Control>}{Enter}{/Control}" : "{Enter}"); - expect(screen.container.querySelector("output")?.dataset.prompt).toBe( - `See ${serializeAssistantCitation(withAssistantCitationComment(sourceCitation, "ok"))} `, - ); - expect(onSend).toHaveBeenCalledTimes(action === "send" ? 1 : 0); + await vi.waitFor(() => { + expect(screen.container.querySelector("output")?.dataset.prompt).toBe( + `See ${serializeAssistantCitation(withAssistantCitationComment(sourceCitation, "ok"))} `, + ); + expect(onSend).toHaveBeenCalledTimes(action === "send" ? 1 : 0); + }); } }, ); From e9e7372a16fbde13d2aa530b375eee52ba2c3d7e Mon Sep 17 00:00:00 2001 From: Arham Mahajan Date: Sat, 31 Oct 2026 18:37:40 +0530 Subject: [PATCH 5/6] fix(web): show citation comment URL limit errors --- ...ditorTiptap.citationDraft.browser.test.tsx | 39 +++++++++++++++---- .../chat/AssistantCitationCommentEditor.tsx | 25 ++++++++---- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 4b4fda185ee49..5316c4164f179 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -1,5 +1,6 @@ import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { + MAX_CITATION_HREF_LENGTH, formatAssistantCitationHref, parseAssistantCitationHref, serializeAssistantCitation, @@ -105,11 +106,25 @@ 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", "send", "question"] as const)( - "rejects a comment exceeding the complete citation URL budget on %s", - async (action) => { - const sourceCitation = { ...citation, messageId: MessageId.make("界".repeat(17_750)) }; - const oversizedComment = "界".repeat(100); +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, @@ -144,6 +159,12 @@ it.each(["save", "send", "question"] as const)( 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( @@ -162,11 +183,15 @@ it.each(["save", "send", "question"] as const)( }); if (action !== "question") { await expect.element(comment).toHaveValue(oversizedComment); - await comment.fill("ok"); + 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, "ok"))} `, + `See ${serializeAssistantCitation(withAssistantCitationComment(sourceCitation, acceptedComment))} `, ); expect(onSend).toHaveBeenCalledTimes(action === "send" ? 1 : 0); }); diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index d9fd60645618e..15632778e25d0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,4 +1,9 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { + formatAssistantCitationHref, + parseAssistantCitationHref, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; import { useState, type Ref } from "react"; import { Button } from "../ui/button"; @@ -20,11 +25,18 @@ export function AssistantCitationCommentEditor({ }) { const [comment, setComment] = useState(citation.comment ?? ""); 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 { @@ -48,7 +60,7 @@ 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} placeholder="Add an optional comment..." rows={2} 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" @@ -73,10 +85,9 @@ export function AssistantCitationCommentEditor({ } }} /> - {commentTooLong ? ( + {commentError ? (

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

) : null}
@@ -90,7 +101,7 @@ export function AssistantCitationCommentEditor({