From 7db3e135e91e109364136eabfc2b5ffa4d98b740 Mon Sep 17 00:00:00 2001 From: AviationNerd Date: Fri, 13 Nov 2026 00:40:16 +0000 Subject: [PATCH 1/3] fix(dashboard): grow the task field on first paste Long pasted prompts could stay at the initial height when browser content sizing did not expand the Task field. The field now grows to its existing maximum and scrolls beyond it. Typing and pasting into existing text keep working. Shared prompts remain expanded until edited. --- docs/dashboard-frontend.md | 3 +- web/e2e/task-autosize.spec.ts | 105 ++++++++++++++++++ .../components/launch/launch-dialog.test.tsx | 11 ++ web/src/components/launch/run-fields.tsx | 27 ++++- 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 web/e2e/task-autosize.spec.ts diff --git a/docs/dashboard-frontend.md b/docs/dashboard-frontend.md index cf66da07e..a0ca1ad6f 100644 --- a/docs/dashboard-frontend.md +++ b/docs/dashboard-frontend.md @@ -2631,7 +2631,8 @@ then reveals it. A refusal shows the server's error and any setup output. The launch form lives in `components/launch/`. It asks for a task, an agent, an account when one is shared with the caller, a mode and a -model. The agent picker is a list of rows from `agent.list`: the vendor glyph +model. The Task field grows with ordinary input to 192px, then scrolls inside +the field. The agent picker is a list of rows from `agent.list`: the vendor glyph in its colour, the display name, and "Ready" or "Sign in after launch" for a shipped agent (`login_found` checks the launch home for login files, not for a working session; a server that does not report it shows nothing), or diff --git a/web/e2e/task-autosize.spec.ts b/web/e2e/task-autosize.spec.ts new file mode 100644 index 000000000..7c780d10d --- /dev/null +++ b/web/e2e/task-autosize.spec.ts @@ -0,0 +1,105 @@ +import type { Locator, Page } from '@playwright/test' + +import { expect, test } from './fixtures' +import { seedWorkspace } from './harness/setup' + +const viewports = [ + { viewport: { width: 1440, height: 900 }, phone: false }, + { viewport: { width: 390, height: 844 }, phone: true }, +] as const + +async function openLaunch(page: Page, phone: boolean): Promise { + const navigation = phone + ? page.getByRole('navigation', { name: 'Main navigation' }) + : page.getByRole('navigation', { name: 'Aether' }) + await navigation.getByRole('button', { name: 'New session' }).click() + const dialog = page.getByRole('dialog', { name: 'New session' }) + await expect(dialog).toBeVisible() + return dialog.getByLabel('Task') +} + +async function paste(page: Page, field: Locator, text: string): Promise { + await page.evaluate((value) => navigator.clipboard.writeText(value), text) + await field.focus() + await field.press('ControlOrMeta+V') +} + +async function fieldHeight(field: Locator): Promise { + return field.evaluate((element) => element.getBoundingClientRect().height) +} + +async function assertCappedAndScrollable(field: Locator): Promise { + await expect.poll(() => fieldHeight(field)).toBe(192) + await expect.poll(() => field.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) +} + +test('Task grows on native paste and typing, caps at 192px, and can be cleared', async ({ browser, aether }) => { + const alice = await aether.member('alice') + await seedWorkspace(alice, aether.server.addr, await aether.seedRepo('shop')) + + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: viewport.viewport, + isMobile: viewport.phone, + hasTouch: viewport.phone, + colorScheme: 'dark', + }) + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(alice.url).origin }) + const page = await context.newPage() + await page.goto(alice.url) + await page.addStyleTag({ content: 'textarea[data-slot="textarea"] { field-sizing: fixed !important; }' }) + const field = await openLaunch(page, viewport.phone) + + const initialHeight = await fieldHeight(field) + const multiline = Array.from({ length: 24 }, (_, index) => `line ${index + 1}`).join('\n') + await paste(page, field, multiline) + await expect(field).toHaveValue(multiline) + await assertCappedAndScrollable(field) + await field.evaluate((element) => { element.scrollTop = 0 }) + await field.press('End') + await expect.poll(() => field.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + + await field.fill('') + await field.pressSequentially('typed first line\ntyped second line\ntyped third line') + await expect(field).toHaveValue('typed first line\ntyped second line\ntyped third line') + await expect.poll(() => fieldHeight(field)).toBeGreaterThan(initialHeight) + + await field.fill('') + const paragraph = 'long prompt word '.repeat(160) + await paste(page, field, paragraph) + await expect(field).toHaveValue(paragraph) + await assertCappedAndScrollable(field) + await field.press('End') + await field.pressSequentially('x') + await expect(field).toHaveValue(paragraph + 'x') + await expect.poll(() => field.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + + await field.fill('prefilled prompt') + await field.press('End') + const appended = '\n' + 'padded prompt word '.repeat(100) + await paste(page, field, appended) + await expect(field).toHaveValue('prefilled prompt' + appended) + await assertCappedAndScrollable(field) + + await field.press('ControlOrMeta+A') + await field.press('Backspace') + await expect(field).toHaveValue('') + await expect.poll(() => fieldHeight(field)).toBe(initialHeight) + + const sharedURL = new URL(alice.url) + sharedURL.searchParams.set('new', '1') + sharedURL.searchParams.set('prompt', multiline) + await page.goto(sharedURL.toString()) + await page.addStyleTag({ content: 'textarea[data-slot="textarea"] { field-sizing: fixed !important; }' }) + const sharedField = page.getByRole('dialog', { name: 'New session' }).getByLabel('Task') + await expect(sharedField).toHaveValue(multiline) + await expect.poll(() => fieldHeight(sharedField)).toBeGreaterThan(192) + await expect.poll(() => sharedField.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeLessThanOrEqual(1) + await sharedField.press('End') + await sharedField.pressSequentially('x') + await expect(sharedField).toHaveValue(multiline + 'x') + await assertCappedAndScrollable(sharedField) + + await context.close() + } +}) diff --git a/web/src/components/launch/launch-dialog.test.tsx b/web/src/components/launch/launch-dialog.test.tsx index 7029b7c1f..cfd523a7f 100644 --- a/web/src/components/launch/launch-dialog.test.tsx +++ b/web/src/components/launch/launch-dialog.test.tsx @@ -115,6 +115,17 @@ describe('child runs switch', () => { }) describe('new run', () => { + it('resizes the task textarea from its controlled value', async () => { + await open() + const task = screen.getByLabelText('Task') as HTMLTextAreaElement + task.style.border = '1px solid' + Object.defineProperty(task, 'scrollHeight', { configurable: true, get: () => 300 }) + + fireEvent.change(task, { target: { value: 'a long task' } }) + + await waitFor(() => expect(Number.parseFloat(task.style.height)).toBeGreaterThanOrEqual(300)) + }) + it('returns focus to what had it before the dialog opened', async () => { render() const before = screen.getByRole('button', { name: 'Board' }) diff --git a/web/src/components/launch/run-fields.tsx b/web/src/components/launch/run-fields.tsx index 11e534c10..24f9b03a7 100644 --- a/web/src/components/launch/run-fields.tsx +++ b/web/src/components/launch/run-fields.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type * as React from 'react' import { DictationButton } from '@/components/dictation/dictation-button' import { DictationNotice } from '@/components/dictation/dictation-notice' @@ -204,7 +204,7 @@ export function TaskField({ mode, task, onTask, autoFocus = false, shared = fals )} - + ) @@ -230,9 +230,30 @@ export function AgentOptions({ choice, onSetUp, onChoice, disabled = false }: { /** Takes the id and descriptions FormField gives its child, so the label still names the textarea above the mic. */ function TaskBox({ onTask, ...props }: React.ComponentProps<'textarea'> & { value: string; onTask: (task: string) => void }) { const textarea = useRef(null) + const resize = useCallback(() => { + const field = textarea.current + if (!field) return + field.style.height = '0px' + const style = getComputedStyle(field) + const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth) + field.style.height = `${field.scrollHeight + borders}px` + }, []) + useLayoutEffect(resize, [props.value, props.className, resize]) + useEffect(() => { + const field = textarea.current + if (!field) return + let width = 0 + const observer = new ResizeObserver(([entry]) => { + if (entry.contentRect.width === width) return + width = entry.contentRect.width + resize() + }) + observer.observe(field) + return () => observer.disconnect() + }, [resize]) return (
-