Skip to content

Commit ee36e89

Browse files
committed
feat(web): clicking a draft quote chip shows the quote with its comment ready to edit
The draft chip in the composer now opens the same popover as a sent quote chip: the quoted text, the comment, and Go to source. The comment is a focused textarea with the caret at its end. The chip itself looks the same. Right after a selection, the comment box still opens under the highlighted text on its own.
1 parent ff52547 commit ee36e89

4 files changed

Lines changed: 302 additions & 167 deletions

File tree

‎apps/web/src/components/ComposerPromptEditorTiptap.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
477477
>
478478
<AssistantCitationChip
479479
citation={citation}
480-
composer
481480
commentEditor={{
482481
open: commentTarget !== null,
483482
sourceAnchor: commentTarget?.sourceAnchor,

‎apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx‎

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from "@t3tools/contracts";
77
import { useState, type ReactNode } from "react";
88
import { describe, expect, it, vi } from "vite-plus/test";
9-
import { userEvent } from "vite-plus/test/browser";
9+
import { page, userEvent } from "vite-plus/test/browser";
1010

1111
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
1212
import "~/index.css";
@@ -20,7 +20,7 @@ vi.mock(import("./AssistantCitationSource"), async (importOriginal) => ({
2020
vi.mock(import("@tanstack/react-router"), async (importOriginal) => ({
2121
...(await importOriginal()),
2222
useNavigate: () => vi.fn(),
23-
Link: (({ children }: { children: ReactNode }) => <a>{children}</a>) as never,
23+
Link: (({ children }: { children: ReactNode }) => <a href="#source">{children}</a>) as never,
2424
}));
2525

2626
import { AssistantCitationChip } from "./AssistantCitationChip";
@@ -50,14 +50,23 @@ function setup() {
5050
return { sourceAnchor, [Symbol.dispose]: () => source.remove() };
5151
}
5252

53-
function mount(sourceAnchor: AssistantCitationSourceAnchor, onSave: () => boolean) {
53+
function mount(
54+
sourceAnchor: AssistantCitationSourceAnchor,
55+
onSave: () => boolean,
56+
onCancel?: () => void,
57+
) {
5458
function Composer() {
5559
const [open, setOpen] = useState(true);
5660
return (
5761
<AssistantCitationChip
5862
citation={citation}
59-
composer
60-
commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
63+
commentEditor={{
64+
open,
65+
sourceAnchor,
66+
onOpenChange: setOpen,
67+
onSave,
68+
...(onCancel ? { onCancel } : {}),
69+
}}
6170
/>
6271
);
6372
}
@@ -83,12 +92,10 @@ async function removeSource() {
8392
await act(async () => onUnavailable());
8493
}
8594

86-
async function clickButton(label: string) {
87-
const button = [...document.querySelectorAll("button")].find(
88-
(element) => element.textContent?.trim() === label,
89-
);
90-
expect(button, `button ${label}`).toBeDefined();
91-
await act(async () => button!.click());
95+
// Once the source is gone the popup moves to the chip, which saves with Enter.
96+
async function pressEnter() {
97+
textarea()!.focus();
98+
await userEvent.keyboard("{Enter}");
9299
}
93100

94101
describe("citation comment source disappearance", () => {
@@ -107,7 +114,7 @@ describe("citation comment source disappearance", () => {
107114
expect(mocks.observeSource).toHaveBeenCalledTimes(1);
108115

109116
await typeComment("shortened comment");
110-
await clickButton("Save");
117+
await pressEnter();
111118
expect(onSave).toHaveBeenCalledWith("shortened comment");
112119
expect(textarea()).toBeNull();
113120
});
@@ -122,10 +129,26 @@ describe("citation comment source disappearance", () => {
122129
expect(onSave).toHaveBeenCalledWith("keep this draft");
123130
expect(textarea()?.value).toBe("keep this draft");
124131
onSave.mockReturnValue(true);
125-
await clickButton("Save");
132+
await pressEnter();
126133
expect(onSave).toHaveBeenLastCalledWith("keep this draft");
127134
expect(textarea()).toBeNull();
128135
});
136+
137+
it("removes a just-inserted quote on Escape from Go to source", async () => {
138+
using source = setup();
139+
const onCancel = vi.fn();
140+
await using _screen = await mount(
141+
source.sourceAnchor,
142+
vi.fn(() => true),
143+
onCancel,
144+
);
145+
await typeComment("x".repeat(ASSISTANT_CITATION_MAX_COMMENT_LENGTH + 1));
146+
await removeSource();
147+
148+
page.getByRole("link", { name: "Go to source" }).element().focus();
149+
await userEvent.keyboard("{Escape}");
150+
expect(onCancel).toHaveBeenCalledTimes(1);
151+
});
129152
});
130153

131154
describe("citation comment popup position", () => {
@@ -175,3 +198,57 @@ describe("citation comment popup position", () => {
175198
expect(rect.right).toBeLessThanOrEqual(window.innerWidth);
176199
});
177200
});
201+
202+
describe("draft citation chip", () => {
203+
it("opens the quote with an editable comment, ready to type at its end", async () => {
204+
using _source = setup();
205+
const onSave = vi.fn(() => true);
206+
function Composer() {
207+
const [open, setOpen] = useState(false);
208+
return (
209+
<AssistantCitationChip
210+
citation={{ ...citation, comment: "note" }}
211+
commentEditor={{ open, onOpenChange: setOpen, onSave }}
212+
/>
213+
);
214+
}
215+
await using _screen = await render(<Composer />);
216+
217+
await page.getByRole("button", { name: "Edit citation comment" }).click();
218+
219+
await expect.element(page.getByRole("blockquote")).toHaveTextContent("hello");
220+
await expect.element(page.getByText("Go to source")).toBeVisible();
221+
await vi.waitFor(() => expect(document.activeElement).toBe(textarea()));
222+
expect(textarea()?.selectionStart).toBe("note".length);
223+
expect(page.getByRole("button", { name: "Save" }).query()).toBeNull();
224+
225+
await userEvent.keyboard(" again{Enter}");
226+
expect(onSave).toHaveBeenCalledWith("note again");
227+
expect(textarea()).toBeNull();
228+
});
229+
230+
it("keeps the comment field and its caret in view under a long quote and comment", async () => {
231+
using _source = setup();
232+
function Composer() {
233+
const [open, setOpen] = useState(false);
234+
return (
235+
<AssistantCitationChip
236+
citation={{
237+
...citation,
238+
text: "long line\n".repeat(80),
239+
comment: "long comment\n".repeat(40),
240+
}}
241+
commentEditor={{ open, onOpenChange: setOpen, onSave: () => true }}
242+
/>
243+
);
244+
}
245+
await using _screen = await render(<Composer />);
246+
247+
await page.getByRole("button", { name: "Edit citation comment" }).click();
248+
249+
await vi.waitFor(() => expect(document.activeElement).toBe(textarea()));
250+
await expect.element(page.getByLabelText("Comment on selected text")).toBeInViewport();
251+
const input = textarea()!;
252+
expect(input.scrollTop + input.clientHeight).toBeGreaterThanOrEqual(input.scrollHeight - 1);
253+
});
254+
});

0 commit comments

Comments
 (0)