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
Expand Up @@ -92,5 +92,7 @@ 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("button", { name: "Edit citation comment: check this" }))
.toBeInTheDocument();
});
1 change: 0 additions & 1 deletion apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
>
<AssistantCitationChip
citation={citation}
composer
commentEditor={{
open: commentTarget !== null,
sourceAnchor: commentTarget?.sourceAnchor,
Expand Down
162 changes: 146 additions & 16 deletions apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@ import {
MessageId,
ThreadId,
} from "@t3tools/contracts";
import { useState, type ReactNode } from "react";
import { useState, type MouseEventHandler, type ReactNode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
import { page, userEvent } from "vite-plus/test/browser";

import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import "~/index.css";
import { act, render } from "../../../test/render";

const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn() }));
const mocks = vi.hoisted(() => ({ observeSource: vi.fn(), dispose: vi.fn(), navigate: vi.fn() }));
vi.mock(import("./AssistantCitationSource"), async (importOriginal) => ({
...(await importOriginal()),
observeAssistantCitationCommentSource: mocks.observeSource,
}));
vi.mock(import("@tanstack/react-router"), async (importOriginal) => ({
...(await importOriginal()),
useNavigate: () => vi.fn(),
Link: (({ children }: { children: ReactNode }) => <a>{children}</a>) as never,
useNavigate: () => mocks.navigate,
Link: (({ children, onClick }: { children: ReactNode; onClick?: MouseEventHandler }) => (
<a href="#source" onClick={onClick}>
{children}
</a>
)) as never,
}));

import { AssistantCitationChip } from "./AssistantCitationChip";
Expand All @@ -41,6 +45,7 @@ const citation = {
function setup() {
mocks.observeSource.mockReset().mockReturnValue(mocks.dispose);
mocks.dispose.mockClear();
mocks.navigate.mockClear();
const source = document.createElement("p");
source.textContent = "hello";
document.body.append(source);
Expand All @@ -50,14 +55,23 @@ function setup() {
return { sourceAnchor, [Symbol.dispose]: () => source.remove() };
}

function mount(sourceAnchor: AssistantCitationSourceAnchor, onSave: () => boolean) {
function mount(
sourceAnchor: AssistantCitationSourceAnchor,
onSave: () => boolean,
onCancel?: () => void,
) {
function Composer() {
const [open, setOpen] = useState(true);
return (
<AssistantCitationChip
citation={citation}
composer
commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
commentEditor={{
open,
sourceAnchor,
onOpenChange: setOpen,
onSave,
...(onCancel ? { onCancel } : {}),
}}
/>
);
}
Expand All @@ -83,12 +97,10 @@ async function removeSource() {
await act(async () => onUnavailable());
}

async function clickButton(label: string) {
const button = [...document.querySelectorAll("button")].find(
(element) => element.textContent?.trim() === label,
);
expect(button, `button ${label}`).toBeDefined();
await act(async () => button!.click());
// Once the source is gone the popup moves to the chip, which saves with Enter.
async function pressEnter() {
textarea()!.focus();
await userEvent.keyboard("{Enter}");
}

describe("citation comment source disappearance", () => {
Expand All @@ -107,7 +119,7 @@ describe("citation comment source disappearance", () => {
expect(mocks.observeSource).toHaveBeenCalledTimes(1);

await typeComment("shortened comment");
await clickButton("Save");
await pressEnter();
expect(onSave).toHaveBeenCalledWith("shortened comment");
expect(textarea()).toBeNull();
});
Expand All @@ -122,10 +134,26 @@ describe("citation comment source disappearance", () => {
expect(onSave).toHaveBeenCalledWith("keep this draft");
expect(textarea()?.value).toBe("keep this draft");
onSave.mockReturnValue(true);
await clickButton("Save");
await pressEnter();
expect(onSave).toHaveBeenLastCalledWith("keep this draft");
expect(textarea()).toBeNull();
});

it("removes a just-inserted quote on Escape from Go to source", async () => {
using source = setup();
const onCancel = vi.fn();
await using _screen = await mount(
source.sourceAnchor,
vi.fn(() => true),
onCancel,
);
await typeComment("x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1));
await removeSource();

page.getByRole("link", { name: "Go to source" }).element().focus();
await userEvent.keyboard("{Escape}");
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

describe("citation comment popup position", () => {
Expand Down Expand Up @@ -175,3 +203,105 @@ describe("citation comment popup position", () => {
expect(rect.right).toBeLessThanOrEqual(window.innerWidth);
});
});

describe("draft citation chip", () => {
it("opens the quote with an editable comment, ready to type at its end", async () => {
using _source = setup();
const onSave = vi.fn(() => true);
function Composer() {
const [open, setOpen] = useState(false);
return (
<AssistantCitationChip
citation={{ ...citation, comment: "note" }}
commentEditor={{ open, onOpenChange: setOpen, onSave }}
/>
);
}
await using _screen = await render(<Composer />);

await page.getByRole("button", { name: /^Edit citation comment:/ }).click();

await expect.element(page.getByRole("blockquote")).toHaveTextContent("hello");
await expect.element(page.getByText("Go to source")).toBeVisible();
await vi.waitFor(() => expect(document.activeElement).toBe(textarea()));
expect(textarea()?.selectionStart).toBe("note".length);
expect(page.getByRole("button", { name: "Save" }).query()).toBeNull();

await userEvent.keyboard(" again{Enter}");
expect(onSave).toHaveBeenCalledWith("note again");
expect(textarea()).toBeNull();
});

it("keeps the comment field and its caret in view under a long quote and comment", async () => {
using _source = setup();
function Composer() {
const [open, setOpen] = useState(false);
return (
<AssistantCitationChip
citation={{
...citation,
text: "long line\n".repeat(80),
comment: "long comment\n".repeat(40),
}}
commentEditor={{ open, onOpenChange: setOpen, onSave: () => true }}
/>
);
}
await using _screen = await render(<Composer />);

await page.getByRole("button", { name: /^Edit citation comment:/ }).click();

await vi.waitFor(() => expect(document.activeElement).toBe(textarea()));
await expect.element(page.getByLabelText("Comment on selected text")).toBeInViewport();
const input = textarea()!;
expect(input.scrollTop + input.clientHeight).toBeGreaterThanOrEqual(input.scrollHeight - 1);
});

it("stays on the page while the draft is too long to save", async () => {
using _source = setup();
function Composer() {
const [open, setOpen] = useState(false);
return (
<AssistantCitationChip
citation={citation}
commentEditor={{ open, onOpenChange: setOpen, onSave: () => true }}
/>
);
}
await using _screen = await render(<Composer />);
await page.getByRole("button", { name: /^Add comment to citation:/ }).click();
await vi.waitFor(() => expect(textarea()).not.toBeNull());

await typeComment("x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1));
await page.getByRole("link", { name: "Go to source" }).click();
expect(mocks.navigate).not.toHaveBeenCalled();
expect(textarea()).not.toBeNull();

await typeComment("short");
await page.getByRole("link", { name: "Go to source" }).click();
expect(mocks.navigate).toHaveBeenCalledTimes(1);
});

it("stays on the page when saving the draft fails", async () => {
using _source = setup();
const onSave = vi.fn(() => false);
function Composer() {
const [open, setOpen] = useState(false);
return (
<AssistantCitationChip
citation={citation}
commentEditor={{ open, onOpenChange: setOpen, onSave }}
/>
);
}
await using _screen = await render(<Composer />);
await page.getByRole("button", { name: /^Add comment to citation:/ }).click();
await vi.waitFor(() => expect(textarea()).not.toBeNull());

await typeComment("changed");
await page.getByRole("link", { name: "Go to source" }).click();
expect(onSave).toHaveBeenCalledWith("changed");
expect(mocks.navigate).not.toHaveBeenCalled();
expect(textarea()?.value).toBe("changed");
});
});
Loading
Loading