diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index 92b1ef9..eee3d29 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -288,7 +288,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro useLayoutEffect(() => { const textarea = textareaRef.current; if (!textarea) return; - textarea.style.height = "0px"; + // scrollHeight reflects the full draft even when the textarea is already + // capped. Avoid collapsing a focused field to its minimum height while + // measuring: the browser may scroll the enclosing transcript to reveal its + // caret, and no ResizeObserver can correct that transient scroll once the + // field returns to the same capped height. textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`; }, [draft]); diff --git a/web/tests/agent-composer-layout-fixture.html b/web/tests/agent-composer-layout-fixture.html new file mode 100644 index 0000000..976d537 --- /dev/null +++ b/web/tests/agent-composer-layout-fixture.html @@ -0,0 +1,8 @@ + + + + Agent composer layout fixture + + +
+ diff --git a/web/tests/agent-composer-layout-fixture.tsx b/web/tests/agent-composer-layout-fixture.tsx new file mode 100644 index 0000000..ae36307 --- /dev/null +++ b/web/tests/agent-composer-layout-fixture.tsx @@ -0,0 +1,29 @@ +import { render } from "preact"; +import { useMemo } from "preact/hooks"; +import type { Message } from "../src/lib/api"; +import type { AgentRoomHookResult } from "../src/hooks/use-agent-room"; +import { AgentConversation } from "../src/components/agent/agent-conversation"; +import "../src/index.css"; + +const createdAt = "2026-11-08T12:00:00.000Z"; +const paragraph = "Synthetic transcript content for composer resize and scroll anchoring. ".repeat(5); +function message(index: number, content: string): Message { + return { id: `layout-${index}`, createdAt: new Date(Date.parse(createdAt) + index * 1000).toISOString(), + role: index % 2 ? "assistant" : "user", content, name: null, metadata: { kind: "message", status: "complete" } }; +} + +function Fixture() { + const history = useMemo(() => Array.from({ length: 24 }, (_, i) => message(i, `${paragraph}${paragraph}`)), []); + const messages = [...history, message(24, `${paragraph}${paragraph}`)]; + const uploadQueue = { snapshot: () => [], add: () => undefined, remove: () => undefined, ready: () => [], + reselect: async () => undefined, retry: () => undefined, clear: () => undefined }; + const room = { messages, activity: null, unreadCount: 0, connection: "connected", error: null, isLoading: false, + isLoadingOlder: false, olderError: null, hasOlder: false, historyBeforeCursor: null, attachmentCapability: undefined, + uploadQueue, uploadAttachment: async () => { throw new Error("not used"); }, downloadAttachment: async () => new Blob(), + isTyping: false, sendMessage: async () => ({ status: "queued" }), retryMessage: async () => undefined, + markRead: () => undefined, retry: () => undefined, loadOlder: () => undefined, unreadCountIsPartial: false, + } as unknown as AgentRoomHookResult; + return undefined} />; +} + +render(, document.getElementById("root")!); diff --git a/web/tests/agent-composer-layout.browser.mjs b/web/tests/agent-composer-layout.browser.mjs new file mode 100644 index 0000000..070c0ce --- /dev/null +++ b/web/tests/agent-composer-layout.browser.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'vite'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const browser = process.env.CHROME_EXECUTABLE || '/home/buzzert/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'; +const profile = await mkdtemp(`${tmpdir()}/sybil-agent-composer-layout-`); +const server = await createServer({ configFile: resolve(root, 'vite.config.ts'), root, logLevel: 'error', + server: { host: '127.0.0.1', port: 0, strictPort: false } }); +let child, socket; +try { + await server.listen(); + const address = server.httpServer.address(); + const port = await new Promise((resolvePort, reject) => { + child = spawn(browser, ['--headless', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', + '--remote-debugging-port=0', '--remote-allow-origins=*', `--user-data-dir=${profile}`, '--no-first-run']); + let diagnostics = ''; + const timeout = setTimeout(() => reject(new Error(`Chrome startup failed: ${diagnostics.slice(-500)}`)), 10000); + child.stderr.setEncoding('utf8').on('data', chunk => { + diagnostics += chunk; + const match = diagnostics.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); + if (match) { clearTimeout(timeout); resolvePort(Number(match[1])); } + }); + child.once('error', error => { clearTimeout(timeout); reject(error); }); + child.once('close', code => { clearTimeout(timeout); reject(new Error(`Chrome exited (${code})`)); }); + }); + const response = await fetch(`http://127.0.0.1:${port}/json/new?http://127.0.0.1:${address.port}/tests/agent-composer-layout-fixture.html`, { method: 'PUT' }); + assert.equal(response.status, 200); + const target = await response.json(); + socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolveOpen, reject) => { socket.addEventListener('open', resolveOpen, { once: true }); socket.addEventListener('error', reject, { once: true }); }); + let id = 0; + const pending = new Map(); + socket.addEventListener('message', event => { + const message = JSON.parse(event.data), request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result); + }); + const call = (method, params = {}) => new Promise((resolveCall, reject) => { + const requestId = ++id; pending.set(requestId, { resolve: resolveCall, reject }); + socket.send(JSON.stringify({ id: requestId, method, params })); + }); + await call('Page.enable'); await call('Runtime.enable'); + await call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { + window.__layoutTrace = []; + const Native = window.ResizeObserver; + window.ResizeObserver = class extends Native { + constructor(callback) { super((entries, observer) => { + const time = performance.now(); + for (const entry of entries) window.__layoutTrace.push({ kind: 'resize', time, + target: entry.target.className || entry.target.tagName, + height: entry.contentRect.height, clientHeight: entry.target.clientHeight, + scrollHeight: entry.target.scrollHeight, scrollTop: entry.target.scrollTop }); + callback(entries, observer); + }); } + }; + document.addEventListener('scroll', event => { + const el = event.target; + if (el instanceof HTMLElement && el.classList.contains('agent-room__scroll')) window.__layoutTrace.push({ kind: 'scroll', time: performance.now(), + clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, scrollTop: el.scrollTop, + gap: el.scrollHeight - el.scrollTop - el.clientHeight }); + }, true); + })()` }); + const configurations = [{ width: 1200, height: 900, dpr: 1 }, { width: 803, height: 777, dpr: 1.25 }, { width: 389, height: 844, dpr: 2 }]; + const all = []; + for (const config of configurations) { + await call('Emulation.setDeviceMetricsOverride', { width: config.width, height: config.height, deviceScaleFactor: config.dpr, mobile: config.width < 768 }); + await call('Page.navigate', { url: `http://127.0.0.1:${address.port}/tests/agent-composer-layout-fixture.html?case=${config.width}` }); + const result = await call('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `new Promise(resolve => { + const wait = () => { + const scroller = document.querySelector('.agent-room__scroll'), textarea = document.querySelector('textarea[aria-label="Message Sybil Agent"]'); + if (!scroller || !textarea) return setTimeout(wait, 20); + setTimeout(async () => { + const settle = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 90)))); + const gap = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; + const visibleAnchor = () => [...scroller.querySelectorAll('[data-agent-entry-id]')].find(row => row.getBoundingClientRect().bottom > scroller.getBoundingClientRect().top + 8); + const sample = label => { const row = visibleAnchor(); return { label, top: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, + gap: gap(), textareaHeight: textarea.getBoundingClientRect().height, textareaScrollHeight: textarea.scrollHeight, + anchorId: row?.dataset.agentEntryId, anchorTop: row?.getBoundingClientRect().top, dpr: devicePixelRatio } }; + const setValue = async (value, label) => { textarea.value = value; textarea.dispatchEvent(new Event('input', { bubbles: true })); await settle(); return sample(label); }; + const result = { config: ${JSON.stringify(config)}, bottom: [], nonbottom: [] }; + scroller.scrollTop = scroller.scrollHeight; scroller.dispatchEvent(new Event('scroll')); await settle(); + result.bottom.push(sample('empty')); + result.bottom.push(await setValue('Short draft', 'short')); + result.bottom.push(await setValue(Array.from({length: 8}, (_,i) => 'Line '+(i+1)).join('\\n'), 'eight-lines')); + result.bottom.push(await setValue(Array.from({length: 26}, (_,i) => 'Wrapped composer line '+(i+1)+' with enough words to wrap at this viewport width.').join('\\n'), 'wrapped-and-capped')); + result.bottom.push(await setValue('one line again', 'shrink')); + result.bottom.push(await setValue('', 'cleared')); + scroller.scrollTop = Math.max(0, (scroller.scrollHeight - scroller.clientHeight) * 0.55); scroller.dispatchEvent(new Event('scroll')); await settle(); + const before = sample('before-nonbottom'); + result.nonbottom.push(await setValue(Array.from({length: 7}, (_,i) => 'Reader draft '+i).join('\\n'), 'nonbottom-grow')); + result.nonbottom.push(await setValue('shrunk reader draft', 'nonbottom-shrink')); + result.nonbottom.push({ before }); + result.trace = (window).__layoutTrace; + resolve(result); + }, 500); + }; wait(); + })` }); + assert.equal(result.exceptionDetails, undefined, result.exceptionDetails?.text); + all.push(result.result.value); + } + if (process.env.RECORD_ONLY !== '1') for (const result of all) { + for (const sample of result.bottom) assert.ok(Math.abs(sample.gap) <= 1, + `bottom pin drift at ${result.config.width}px/${result.config.dpr}x: ${JSON.stringify(sample)}`); + const before = result.nonbottom.find(sample => sample.before)?.before; + for (const sample of result.nonbottom.filter(sample => sample.label?.startsWith('nonbottom-'))) { + assert.equal(sample.anchorId, before.anchorId, `nonbottom anchor changed: ${JSON.stringify({ before, sample })}`); + assert.ok(Math.abs(sample.anchorTop - before.anchorTop) <= 1, `nonbottom transcript jumped: ${JSON.stringify({ before, sample })}`); + } + } + process.stdout.write(JSON.stringify({ browser, all }, null, 2) + '\n'); +} finally { + socket?.close(); + if (child && child.exitCode === null) { const closed = new Promise(done => child.once('close', done)); child.kill('SIGTERM'); await closed; } + await server.close(); await rm(profile, { recursive: true, force: true }); +} diff --git a/web/tests/agent-scroll-fixture.tsx b/web/tests/agent-scroll-fixture.tsx index 9803a0e..31d7446 100644 --- a/web/tests/agent-scroll-fixture.tsx +++ b/web/tests/agent-scroll-fixture.tsx @@ -64,6 +64,16 @@ function Fixture() { [95, shorterReply], [130, "A concise streamed final answer with one extra line.\n\nThe last snapshot settles the message height."], [165, finalReply], ] as const; + window.setTimeout(() => { + const textarea = document.querySelector('textarea[aria-label="Message Sybil Agent"]')!; + textarea.value = Array.from({ length: 8 }, (_, index) => `Concurrent composer draft ${index + 1}`).join("\n"); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, 25); + window.setTimeout(() => { + const textarea = document.querySelector('textarea[aria-label="Message Sybil Agent"]')!; + textarea.value = ""; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, 115); for (const [delay, content] of streamSnapshots) window.setTimeout(() => setRoomState(current => ({ ...current, messages: current.messages.map(item => item.id === "fixture-agent-reply" ? { ...item, content } : item),