Skip to content
Draft
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
3 changes: 2 additions & 1 deletion docs/dashboard-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions web/e2e/task-autosize.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Locator> {
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<void> {
await page.evaluate((value) => navigator.clipboard.writeText(value), text)
await field.focus()
await field.press('ControlOrMeta+V')
}

async function fieldHeight(field: Locator): Promise<number> {
return field.evaluate((element) => element.getBoundingClientRect().height)
}

async function assertCappedAndScrollable(field: Locator): Promise<void> {
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,
Comment thread
AviationNerd marked this conversation as resolved.
Resolved
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()
}
})
11 changes: 11 additions & 0 deletions web/src/components/launch/launch-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<button type="button">Board</button>)
const before = screen.getByRole('button', { name: 'Board' })
Expand Down
27 changes: 24 additions & 3 deletions web/src/components/launch/run-fields.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -204,7 +204,7 @@ export function TaskField({ mode, task, onTask, autoFocus = false, shared = fals
</Callout>
)}
<FormField label="Task" help={help}>
<TaskBox className={cn('[field-sizing:content]', !shared && 'max-h-48')} autoFocus={autoFocus} readOnly={disabled} required={headless} value={task} onTask={onTask} />
<TaskBox className={cn(!shared && 'max-h-48')} autoFocus={autoFocus} readOnly={disabled} required={headless} value={task} onTask={onTask} />
</FormField>
</>
)
Expand All @@ -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<HTMLTextAreaElement>(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])
Comment thread
AviationNerd marked this conversation as resolved.
Resolved
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 (
<div className="flex flex-col gap-1">
<Textarea ref={textarea} className="max-h-48 [field-sizing:content]" rows={3} placeholder="What should the agent do?" onChange={(event) => onTask(event.target.value)} {...props} />
<Textarea rows={3} placeholder="What should the agent do?" onChange={(event) => onTask(event.target.value)} {...props} className={cn(props.className, 'resize-none')} ref={textarea} />
<DictationNotice textarea={textarea} />
<div className="flex justify-end empty:hidden">
<DictationButton textarea={textarea} onInsert={onTask} />
Expand Down
Loading