diff --git a/e2e/workspace-responsive.e2e.ts b/e2e/workspace-responsive.e2e.ts index 7abd49b..5e1dc23 100644 --- a/e2e/workspace-responsive.e2e.ts +++ b/e2e/workspace-responsive.e2e.ts @@ -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, @@ -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 { + 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> { + 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 { + 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) + }) +} diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index fca8324..1d7051b 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -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 () => { @@ -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; @@ -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(); @@ -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)); @@ -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 () => { @@ -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; +} diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index baa7665..4cfa62c 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -1,13 +1,14 @@ "use client"; import { + useId, + useLayoutEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type MouseEvent, type ReactElement, - type UIEvent, } from "react"; import { BarChart3, FileText, Plus, Send } from "lucide-react"; @@ -22,9 +23,9 @@ import { import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; -const chatComposerId = "chat-composer"; -const placeholderPattern = /(\[[^\]\r\n]{1,80}\])/gu; -const placeholderSegmentPattern = /^\[[^\]\r\n]{1,80}\]$/u; +const chatComposerName = "chat-composer"; +const chatComposerTextAreaMinHeight = 128; +const chatComposerTextAreaMaxHeight = 192; const placeholderRangePattern = /\[[^\]\r\n]{1,80}\]/gu; type TextRange = { @@ -52,16 +53,21 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); - const [hasActiveTextSelection, setHasActiveTextSelection] = useState(false); - const [textareaScrollTop, setTextareaScrollTop] = useState(0); + const composerInputId = useId(); + const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); const trimmedInput = input.trim(); const canSend = !isDisabled && !isSending && trimmedInput.length > 0; - const shouldShowHighlightLayer = input.length > 0 && !hasActiveTextSelection; + + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (textarea === null) return; + + resizeComposerTextArea(textarea); + }, [input]); function handleInputChange(event: ChangeEvent): void { setInput(event.target.value); - setHasActiveTextSelection(false); } function handleKeyDown(event: KeyboardEvent): void { @@ -75,37 +81,43 @@ export function ChatComposer({ if (!canSend) return; onSend?.(trimmedInput); setInput(""); - setTextareaScrollTop(0); } function handleTemplateSelect(prompt: string): void { + pendingTemplatePromptRef.current = prompt; setInput(prompt); - setTextareaScrollTop(0); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (!textarea) return; + } - textarea.focus(); - const placeholderRange = getFirstPlaceholderRange(prompt); - if (!placeholderRange) { - textarea.setSelectionRange(prompt.length, prompt.length); - setHasActiveTextSelection(false); - return; - } + function handleCreateMenuCloseAutoFocus(event: Event): void { + const prompt = pendingTemplatePromptRef.current; + if (prompt === null) return; - textarea.setSelectionRange(placeholderRange.start, placeholderRange.end); - setHasActiveTextSelection(true); + event.preventDefault(); + requestAnimationFrame(() => { + focusSelectedTemplatePlaceholder(prompt); }); } - function handleTextareaScroll(event: UIEvent): void { - setTextareaScrollTop(event.currentTarget.scrollTop); + function focusSelectedTemplatePlaceholder(prompt: string): void { + pendingTemplatePromptRef.current = null; + + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.focus({ preventScroll: true }); + const placeholderRange = getFirstPlaceholderRange(prompt); + if (!placeholderRange) { + textarea.setSelectionRange(prompt.length, prompt.length); + return; + } + + textarea.setSelectionRange(placeholderRange.start, placeholderRange.end); + textarea.scrollTop = 0; } function handleTextareaClick(event: MouseEvent): void { const textarea = event.currentTarget; if (textarea.selectionStart !== textarea.selectionEnd) { - setHasActiveTextSelection(true); return; } @@ -114,19 +126,10 @@ export function ChatComposer({ textarea.selectionStart, ); if (!placeholderRange) { - setHasActiveTextSelection(false); return; } textarea.setSelectionRange(placeholderRange.start, placeholderRange.end); - setHasActiveTextSelection(true); - } - - function refreshTextareaSelectionState(): void { - const textarea = textareaRef.current; - setHasActiveTextSelection( - Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd), - ); } return ( @@ -146,25 +149,13 @@ export function ChatComposer({ ) : ( <>
- {shouldShowHighlightLayer && ( - - )}