Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -31,19 +34,23 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `;
function Composer({
question,
editorRef,
sourceCitation = citation,
onSend = () => {},
}: {
question: boolean;
editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
sourceCitation?: AssistantCitation;
onSend?: () => void;
}) {
const [prompt, setPrompt] = useState(promptBefore);
const [prompt, setPrompt] = useState(() => `See ${serializeAssistantCitation(sourceCitation)} `);
const [answer, setAnswer] = useState("");
return (
<>
<div data-assistant-citation-viewport="">
<p
data-assistant-citation-source={citation.messageId}
data-assistant-citation-environment={citation.environmentId}
data-assistant-citation-thread={citation.threadId}
data-assistant-citation-source={sourceCitation.messageId}
data-assistant-citation-environment={sourceCitation.environmentId}
data-assistant-citation-thread={sourceCitation.threadId}
>
hello world
</p>
Expand All @@ -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);
}}
Expand Down Expand Up @@ -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<ComposerPromptEditorHandle>();
const onSend = vi.fn();
await using screen = await render(
<Composer
question={false}
editorRef={editorRef}
sourceCitation={sourceCitation}
onSend={onSend}
/>,
);
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(
<Composer question editorRef={editorRef} sourceCitation={sourceCitation} onSend={onSend} />,
);
} 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);
});
}
},
);
4 changes: 4 additions & 0 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import type {
ServerProviderSkill,
} from "@t3tools/contracts";
import {
formatAssistantCitationHref,
parseAssistantCitationHref,
serializeAssistantCitation,
withAssistantCitationComment,
} from "@t3tools/shared/assistantCitations";
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const tr = editor.state.tr.setNodeMarkup(pos, undefined, {
...current.attrs,
citation: next,
Expand Down Expand Up @@ -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, {
Expand Down
31 changes: 22 additions & 9 deletions apps/web/src/components/chat/AssistantCitationCommentEditor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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}
Comment thread
arhammahajan marked this conversation as resolved.
Resolved
aria-errormessage={commentError ? errorId : undefined}
placeholder="Add an optional comment..."
rows={2}
dir={textDirection(comment)}
Expand All @@ -75,10 +89,9 @@ export function AssistantCitationCommentEditor({
}
}}
/>
{commentTooLong ? (
<p role="status" className="pt-1 text-xs text-destructive">
Comments can contain up to {ASSISTANT_CITATION_MAX_COMMENT_LENGTH.toLocaleString()}{" "}
characters.
{commentError ? (
<p id={errorId} role="status" className="pt-1 text-xs text-destructive">
{commentError}
</p>
) : null}
<div className="mt-2 flex items-center justify-end gap-2">
Expand All @@ -92,7 +105,7 @@ export function AssistantCitationCommentEditor({
</Button>
<Button
size="xs"
disabled={commentTooLong}
disabled={!!commentError}
onPointerDown={(event) => event.preventDefault()}
onClick={submit}
>
Expand Down
24 changes: 18 additions & 6 deletions apps/web/src/components/chat/AssistantSelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) {
clear();
return;
}
const rects = captured.range.getClientRects();
const pointerOnText =
pointer !== null &&
Expand All @@ -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,
Expand Down
69 changes: 50 additions & 19 deletions apps/web/src/lib/assistantCitationNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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" });
Expand All @@ -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();
Expand Down
Loading
Loading