From f6ad3f0d41e533b76bfacb94c9e79246f5377908 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Mon, 22 Jun 2026 15:12:43 +0800 Subject: [PATCH 1/2] fix chat composer long prompt layout --- e2e/workspace-responsive.e2e.ts | 76 +++++++++++++++++++++++++++- src/components/chat-composer.test.ts | 58 +++++++++++++++++++-- src/components/chat-composer.tsx | 59 ++++++++++++++++++--- 3 files changed, 179 insertions(+), 14 deletions(-) diff --git a/e2e/workspace-responsive.e2e.ts b/e2e/workspace-responsive.e2e.ts index 7abd49b..bdb19ec 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,59 @@ 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) + }) +} + +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 +} diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index fca8324..19869bd 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 () => { @@ -115,7 +147,13 @@ describe("ChatComposer", () => { expect(input.className).toContain("text-foreground"); expect(input.className).not.toContain("text-transparent"); + expect( + screen.getByTestId("chat-composer-highlight-layer").className, + ).toContain("text-transparent"); expect(screen.getByText("[Company Name]").className).toContain( + "bg-primary/10", + ); + expect(screen.getByText("[Company Name]").className).not.toContain( "text-primary", ); }); @@ -153,9 +191,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..30bc2c3 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -1,6 +1,8 @@ "use client"; import { + useId, + useLayoutEffect, useRef, useState, type ChangeEvent, @@ -22,7 +24,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 chatComposerName = "chat-composer"; +const chatComposerTextAreaMinHeight = 128; +const chatComposerTextAreaMaxHeight = 192; const placeholderPattern = /(\[[^\]\r\n]{1,80}\])/gu; const placeholderSegmentPattern = /^\[[^\]\r\n]{1,80}\]$/u; const placeholderRangePattern = /\[[^\]\r\n]{1,80}\]/gu; @@ -54,11 +58,23 @@ export function ChatComposer({ const [input, setInput] = useState(""); const [hasActiveTextSelection, setHasActiveTextSelection] = useState(false); const [textareaScrollTop, setTextareaScrollTop] = useState(0); + const [textareaHeight, setTextareaHeight] = useState( + chatComposerTextAreaMinHeight, + ); + const composerInputId = useId(); 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; + + const nextHeight = resizeComposerTextArea(textarea); + setTextareaHeight(nextHeight); + }, [input]); + function handleInputChange(event: ChangeEvent): void { setInput(event.target.value); setHasActiveTextSelection(false); @@ -150,7 +166,8 @@ export function ChatComposer({