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,13 +1,25 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import {
serializeAssistantCitation,
withAssistantCitationComment,
} from "@t3tools/shared/assistantCitations";
import { createRef, useState } from "react";
import { expect, it } from "vite-plus/test";
import { createRef, use, useState } from "react";
import { expect, it, vi } from "vite-plus/test";

import { page, userEvent } from "vite-plus/test/browser";

const navigation = vi.hoisted(() => ({
navigate: vi.fn(),
params: { environmentId: "environment", threadId: "thread" },
}));
vi.mock(import("@tanstack/react-router"), async (importOriginal) => ({
...(await importOriginal()),
useNavigate: () => navigation.navigate,
useParams: (() => navigation.params) as typeof import("@tanstack/react-router").useParams,
}));

import { ComposerContextActionsContext } from "./composerContextActions";
import { findAssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { act, render } from "../../test/render";
import {
Expand All @@ -31,10 +43,14 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `;
function Composer({
question,
editorRef,
sourceVisible = true,
onCommandKeyDown,
disabled = false,
send = "clears",
}: {
question: boolean;
sourceVisible?: boolean;
onCommandKeyDown?: (key: string, event: KeyboardEvent) => boolean;
editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
disabled?: boolean;
send?: "clears" | "blocked" | "refusedLater";
Expand All @@ -43,15 +59,17 @@ function Composer({
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}
>
hello world
</p>
</div>
{sourceVisible ? (
<div data-assistant-citation-viewport="">
<p
data-assistant-citation-source={citation.messageId}
data-assistant-citation-environment={citation.environmentId}
data-assistant-citation-thread={citation.threadId}
>
hello world
</p>
</div>
) : null}
<output data-prompt={prompt} data-answer={answer} />
<ComposerPromptEditor
editorRef={editorRef}
Expand All @@ -65,6 +83,7 @@ function Composer({
onCitationDraftSave={(replacedValue, savedValue) => {
if (replacedValue === prompt) setPrompt(savedValue);
}}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
onCitationSubmitAndSend={() => {
if (send === "blocked") return false;
if (send === "clears") setPrompt("");
Expand Down Expand Up @@ -104,9 +123,129 @@ it("keeps an unsaved citation comment when a question takes the composer", async
expect(drafts()?.prompt).toBe(
`See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `,
);
await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument();
await expect
.element(page.getByRole("link", { name: "View cited assistant text: check this" }))
.toBeInTheDocument();
});

it.each(["click", "Enter", " "])(
"opens the citation comment editor with %s on the chip",
async (activation) => {
const editorRef = createRef<ComposerPromptEditorHandle>();
const onCommandKeyDown = vi.fn(() => true);
await using screen = await render(
<Composer question={false} editorRef={editorRef} onCommandKeyDown={onCommandKeyDown} />,
);
const textbox = screen.getByRole("textbox");
await textbox.click();
await userEvent.keyboard("{Enter}");
expect(onCommandKeyDown).toHaveBeenCalledWith("Enter", expect.any(KeyboardEvent), false);
onCommandKeyDown.mockClear();
const chip = screen.getByRole("link", { name: "View cited assistant text: hello" });
chip.element().focus();
await userEvent.keyboard("{Control>}{Enter}{/Control}");
expect(onCommandKeyDown).not.toHaveBeenCalled();
for (const init of [
{ ctrlKey: true },
{ metaKey: true },
{ shiftKey: true },
{ altKey: true },
{ button: 1 },
]) {
const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...init });
// Keep the browser on the test page while exercising the link's modifier handling.
chip.element().addEventListener("click", (event) => event.preventDefault(), { once: true });
await act(() => {
chip.element().dispatchEvent(event);
});
await expect.element(page.getByLabelText("Comment on selected text")).not.toBeInTheDocument();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if (activation === "click") {
await chip.click();
} else {
screen.container.querySelector<HTMLElement>("[data-citation-comment-trigger]")?.focus();
await userEvent.keyboard(activation === "Enter" ? "{Enter}" : " ");
}
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(chip.element().getAttribute("aria-controls")).toBe(
page.getByRole("dialog").element().id,
);
chip.element().dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
chip.element().dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(onCommandKeyDown).not.toHaveBeenCalled();
expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore);
},
);

it("reanchors the comment when the cited source mounts after activation", async () => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(
<Composer question={false} editorRef={editorRef} sourceVisible={false} />,
);
await screen.getByRole("link", { name: "View cited assistant text: hello" }).click();
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
const viewport = document.createElement("div");
viewport.dataset.assistantCitationViewport = "";
const source = document.createElement("p");
source.style.position = "fixed";
source.style.left = "500px";
source.style.top = "300px";
source.dataset.assistantCitationSource = citation.messageId;
source.dataset.assistantCitationEnvironment = citation.environmentId;
source.dataset.assistantCitationThread = citation.threadId;
source.textContent = "hello world";
viewport.append(source);
await act(() => {
screen.container.append(viewport);
});
await vi.waitFor(() => {
const highlight = CSS.highlights.get("t3-assistant-citation-comment");
expect(highlight?.size).toBe(1);
expect(highlight && [...highlight][0]?.startContainer.parentElement).toBe(source);
const popup = page.getByRole("dialog").element().getBoundingClientRect();
const sourceRect = source.getBoundingClientRect();
const chipRect = screen
.getByRole("link", { name: "View cited assistant text: hello" })
.element()
.getBoundingClientRect();
const distance = (rect: DOMRect) => Math.hypot(popup.left - rect.left, popup.top - rect.bottom);
expect(distance(sourceRect)).toBeLessThan(distance(chipRect));
});
});

it.each([
["environment", "other-thread", "click"],
["environment", "other-thread", "Enter"],
["environment", "other-thread", " "],
["other-environment", "thread", "click"],
["other-environment", "thread", "Enter"],
["other-environment", "thread", " "],
])(
"edits a citation from draft %s/%s with %s without navigating",
async (environmentId, threadId, activation) => {
navigation.navigate.mockClear();
navigation.params = { environmentId, threadId };
try {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(
<Composer question={false} editorRef={editorRef} sourceVisible={false} />,
);
const chip = screen.getByRole("link", { name: "View cited assistant text: hello" });
if (activation === "click") {
await chip.click();
} else {
chip.element().focus();
await userEvent.keyboard(activation === "Enter" ? "{Enter}" : " ");
}
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(navigation.navigate).not.toHaveBeenCalled();
} finally {
navigation.params = { environmentId: "environment", threadId: "thread" };
}
},
);

it("clears the sent prompt when an empty citation comment sends it", async () => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(<Composer question={false} editorRef={editorRef} />);
Expand Down Expand Up @@ -162,6 +301,30 @@ it.each(["blocked", "refusedLater"] as const)(
},
);

it("reveals a side-chat citation using the composer scope on its parent route", async () => {
navigation.navigate.mockClear();
navigation.params = { environmentId: "environment", threadId: "parent-thread" };
const editorRef = createRef<ComposerPromptEditorHandle>();
function SideChatComposer() {
const actions = use(ComposerContextActionsContext);
return (
<ComposerContextActionsContext
value={{ ...actions, threadRef: scopeThreadRef(citation.environmentId, citation.threadId) }}
>
<Composer question={false} editorRef={editorRef} sourceVisible={false} />
</ComposerContextActionsContext>
);
}
try {
await using screen = await render(<SideChatComposer />);
await screen.getByRole("link", { name: "View cited assistant text: hello" }).click();
await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
expect(navigation.navigate).toHaveBeenCalledOnce();
} finally {
navigation.params = { environmentId: "environment", threadId: "thread" };
}
});

it("cancels a new citation while dictation makes the composer read-only", async () => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(<Composer question={false} editorRef={editorRef} disabled />);
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ import {
ComposerContextReferenceChip,
ComposerContextRecordsContext,
} from "./composerContextPresentation";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { type AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
Expand Down Expand Up @@ -504,7 +504,10 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper
as="span"
className={CHIP_NODE_SELECTION_CLASS_NAME}
className={cn(
CHIP_NODE_SELECTION_CLASS_NAME,
"data-[composer-chip-selected]:after:rounded-md",
)}
contentEditable={false}
spellCheck={false}
data-composer-citation-chip="true"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ContextChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const contextChipVariants = cva(
},
shape: {
inline: "",
pill: "mx-[0.15em] gap-1 rounded-md bg-(--context-chip-accent)/12 px-1.5 py-0.5",
bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-sm text-message-foreground",
},
state: {
Expand All @@ -54,7 +55,7 @@ type ContextChipKind = NonNullable<VariantProps<typeof contextChipVariants>["kin
interface ContextChipProps extends useRender.ComponentProps<"span"> {
kind?: ContextChipKind;
state?: "unresolved" | "invalid";
shape?: "inline" | "bubble";
shape?: "inline" | "pill" | "bubble";
}

function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) {
Expand Down
Loading
Loading