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..745491e3a --- /dev/null +++ b/web/e2e/task-autosize.spec.ts @@ -0,0 +1,123 @@ +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 paste and typing, adjusts to width changes, 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) + await expect(field).toHaveCSS('resize', 'none') + + 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('ControlOrMeta+Home') + await field.press('ControlOrMeta+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('ControlOrMeta+Home') + await field.press('ControlOrMeta+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 widthSensitive = 'prompt width response '.repeat(15) + await field.fill(widthSensitive) + const wideWidth = await field.evaluate((element) => element.getBoundingClientRect().width) + const wideHeight = await fieldHeight(field) + await page.setViewportSize({ width: 320, height: viewport.viewport.height }) + await expect.poll(() => field.evaluate((element) => element.getBoundingClientRect().width)).toBeLessThan(wideWidth) + await expect.poll(() => fieldHeight(field)).toBeGreaterThan(wideHeight) + const narrowWidth = await field.evaluate((element) => element.getBoundingClientRect().width) + const narrowHeight = await fieldHeight(field) + await page.setViewportSize(viewport.viewport) + await expect.poll(() => field.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThan(narrowWidth) + await expect.poll(() => fieldHeight(field)).toBeLessThan(narrowHeight) + await expect(field).toHaveValue(widthSensitive) + + 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('ControlOrMeta+Home') + await sharedField.press('ControlOrMeta+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..c12073ba9 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 (
-