Skip to content
Merged
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
114 changes: 112 additions & 2 deletions e2e/workspace-responsive.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
import { expect, test } from "@playwright/test"
import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test"

type ComposerViewport = {
readonly name: string
readonly width: number
readonly height: number
}

const composerRegressionViewports: readonly ComposerViewport[] = [
{ name: "desktop", width: 1280, height: 832 },
{ name: "mobile", width: 390, height: 844 },
]

const longResearchPrompt = [
"You are a sell-side research analyst preparing a post earnings flash note.",
"Please complete the following tasks:",
"1. Extract management's original wording on revenue guidance, gross margin, and capex.",
"2. Compare the wording with the prior quarter and call out any directional changes.",
"3. Summarize the implications in concise bullets for institutional investors.",
"4. Include source-backed evidence for each conclusion.",
].join("\n")

test("fits desktop notebook panels inside a 13-inch viewport", async ({
context,
Expand Down Expand Up @@ -60,7 +80,97 @@ test("uses the tabbed notebook layout below the desktop panel minimum", async ({
await expect(page.getByTestId("desktop-panel-layout")).toBeHidden()
await expect(
page.getByRole("tab", {
name: /Assistant/u,
name: /Chat/u,
}),
).toBeVisible()
})

for (const viewport of composerRegressionViewports) {
test(`keeps long chat prompts clear of composer actions on ${viewport.name}`, async ({
context,
page,
}) => {
await openAuthenticatedNotebook(context, page, viewport)

const input = page.getByRole("textbox", { name: "Chat message" })
const sendButton = page.getByRole("button", { name: "Send message" })

await expect(input).toBeVisible()
await input.fill(longResearchPrompt)
await expect(sendButton).toBeVisible()
await expect(sendButton).toBeEnabled()

const inputBounds = await getRequiredBounds(input, "chat composer input")
const sendBounds = await getRequiredBounds(sendButton, "send button")

expect(inputBounds.y + inputBounds.height).toBeLessThanOrEqual(sendBounds.y)
})

test(`keeps the first prompt placeholder selected after menu close on ${viewport.name}`, async ({
context,
page,
}) => {
await openAuthenticatedNotebook(context, page, viewport)

const input = page.getByRole("textbox", { name: "Chat message" })
await page.getByRole("button", { name: "Create" }).click()
await page
.getByRole("menuitem", { name: "Earnings Call Transcript Analysis" })
.click()

await expect(input).toBeFocused()
await expect
.poll(async () => getSelectedComposerText(input))
.toBe("[Company Name]")

await page.waitForTimeout(400)

await expect(input).toBeFocused()
expect(await getSelectedComposerText(input)).toBe("[Company Name]")
expect(
await input.evaluate((element) => {
return element.scrollTop
}),
).toBe(0)
})
}

async function openAuthenticatedNotebook(
context: BrowserContext,
page: Page,
viewport: ComposerViewport,
): Promise<void> {
await context.addCookies([
{
name: "better-auth.session_token",
value: "playwright",
url: "http://localhost:3000",
},
])
await page.setViewportSize({ width: viewport.width, height: viewport.height })
await page.goto("/e2e/citation-dedupe")
}

async function getRequiredBounds(
locator: Locator,
label: string,
): Promise<Readonly<{ x: number; y: number; width: number; height: number }>> {
const bounds = await locator.boundingBox()

expect(bounds, `${label} should have a bounding box`).not.toBeNull()
if (bounds === null) {
throw new Error(`${label} did not have a bounding box.`)
}

return bounds
}

async function getSelectedComposerText(locator: Locator): Promise<string> {
return locator.evaluate((element) => {
if (!(element instanceof HTMLTextAreaElement)) {
throw new Error("Expected chat composer input to be a textarea.")
}

return element.value.slice(element.selectionStart, element.selectionEnd)
})
}
67 changes: 55 additions & 12 deletions src/components/chat-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,46 @@ describe("ChatComposer", () => {

render(React.createElement(ChatComposer, { onSend }));

const input = screen.getByPlaceholderText(
"Ask a question about your documents…",
);
const input = getComposerTextArea();
await user.type(input, " Summarize this document ");
await user.click(screen.getByRole("button", { name: "Send message" }));

expect(onSend).toHaveBeenCalledWith("Summarize this document");
expect((input as HTMLTextAreaElement).value).toBe("");
expect(input.value).toBe("");
});

it("caps long prompts and resets the composer after sending", async () => {
const user = userEvent.setup();
const onSend = vi.fn();

render(React.createElement(ChatComposer, { onSend }));

const input = getComposerTextArea();
Object.defineProperty(input, "scrollHeight", {
configurable: true,
get: () => 260,
});

fireEvent.change(input, {
target: {
value:
"Line one\nLine two\nLine three\nLine four\nLine five\nLine six\nLine seven\nLine eight",
},
});

await waitFor(() => {
expect(input.style.height).toBe("192px");
expect(input.style.overflowY).toBe("auto");
});

await user.click(screen.getByRole("button", { name: "Send message" }));

expect(onSend).toHaveBeenCalledOnce();
await waitFor(() => {
expect(input.value).toBe("");
expect(input.style.height).toBe("128px");
expect(input.style.overflowY).toBe("hidden");
});
});

it("shows the guest login action instead of the text composer", async () => {
Expand Down Expand Up @@ -63,14 +95,13 @@ describe("ChatComposer", () => {

render(React.createElement(ChatComposer));

const input = getComposerTextArea();
input.scrollTop = 92;
await user.click(screen.getByRole("button", { name: "Create" }));
await user.click(
screen.getByRole("menuitem", { name: /IPO Prospectus Risk Mining/ }),
);

const input = screen.getByPlaceholderText(
"Ask a question about your documents…",
) as HTMLTextAreaElement;
const placeholderStart = input.value.indexOf("[Company Name]");
const placeholderEnd = placeholderStart + "[Company Name]".length;

Expand All @@ -79,6 +110,8 @@ describe("ChatComposer", () => {
expect(input.selectionStart).toBe(placeholderStart);
expect(input.selectionEnd).toBe(placeholderEnd);
});
expect(document.activeElement).toBe(input);
expect(input.scrollTop).toBe(0);
expect(input.className).toContain("text-foreground");
expect(input.className).not.toContain("text-transparent");
expect(screen.queryByTestId("chat-composer-highlight-layer")).toBeNull();
Expand All @@ -95,7 +128,7 @@ describe("ChatComposer", () => {
expect(input.className).not.toContain("text-transparent");
});

it("highlights placeholders when text is not selected", async () => {
it("uses native textarea selection without rendering a mirror highlight layer", async () => {
const user = userEvent.setup();

render(React.createElement(ChatComposer));
Expand All @@ -110,14 +143,14 @@ describe("ChatComposer", () => {
) as HTMLTextAreaElement;
await waitFor(() => expect(input.value).toContain("[Company Name]"));

expect(screen.queryByTestId("chat-composer-highlight-layer")).toBeNull();

input.setSelectionRange(input.value.length, input.value.length);
fireEvent.select(input);

expect(input.className).toContain("text-foreground");
expect(input.className).not.toContain("text-transparent");
expect(screen.getByText("[Company Name]").className).toContain(
"text-primary",
);
expect(screen.queryByTestId("chat-composer-highlight-layer")).toBeNull();
});

it("selects a placeholder with one click when the caret lands inside it", async () => {
Expand Down Expand Up @@ -153,9 +186,19 @@ describe("ChatComposer", () => {
"Ask a question about your documents…",
);

expect(input.className).toContain("h-[128px]");
expect(input.className).toContain("min-h-[128px]");
expect(input.className).toContain("max-h-[192px]");
expect(input.className).toContain("border-0");
expect(input.className).toContain("shadow-none");
expect(screen.getByRole("button", { name: "Create" })).toBeTruthy();
});
});

function getComposerTextArea(): HTMLTextAreaElement {
const element = screen.getByRole("textbox", { name: "Chat message" });
if (!(element instanceof HTMLTextAreaElement)) {
throw new Error("Expected the chat composer input to be a textarea.");
}

return element;
}
Loading
Loading