diff --git a/packages/app/e2e/regression/session-prompt-spacing.spec.ts b/packages/app/e2e/regression/session-prompt-spacing.spec.ts new file mode 100644 index 00000000000..6719a317fc4 --- /dev/null +++ b/packages/app/e2e/regression/session-prompt-spacing.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test" +import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture" + +test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => { + await setupTimeline(page, { messages: [userMessage()], seedHistory: true }) + const release = Promise.withResolvers() + await page.route(`**/api/session/${sessionID}/prompt`, async (route) => { + if (route.request().method() !== "POST") return route.fallback() + await release.promise + return route.fallback() + }) + + const editor = page.locator('[data-component="composer"]').getByRole("textbox") + await expect(editor).toBeEditable() + await editor.fill("Observe optimistic prompt spacing.") + await expect + .poll(() => + page.locator("[data-timeline-virtual-content]").evaluate((element) => { + const root = element.parentElement! + return root.scrollHeight - root.clientHeight - root.scrollTop + }), + ) + .toBe(0) + + const observation = await page.evaluateHandle(() => { + const frames: { prompt?: number; working: boolean }[] = [] + let frame = 0 + const sample = () => { + const prompt = [...document.querySelectorAll('[data-timeline-row="UserMessage"]')].find((row) => + row.textContent?.includes("Observe optimistic prompt spacing."), + ) + frames.push({ + ...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}), + working: !!document.querySelector('[data-component="session-working"]'), + }) + frame = requestAnimationFrame(sample) + } + frame = requestAnimationFrame(sample) + return { + stop: () => { + cancelAnimationFrame(frame) + return frames + }, + } + }) + const requested = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`, + ) + try { + await editor.press("Enter") + await requested + const prompt = page + .locator('[data-timeline-row="UserMessage"]') + .filter({ hasText: "Observe optimistic prompt spacing." }) + await expect(prompt).toBeInViewport() + await expect(page.locator('[data-component="session-working"]')).toBeVisible() + await expect + .poll(() => + page.locator("[data-timeline-virtual-content]").evaluate((element) => { + const root = element.parentElement! + return root.scrollHeight - root.clientHeight - root.scrollTop + }), + ) + .toBe(0) + const frames = await observation.evaluate((value) => value.stop()) + expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false) + const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt])) + expect(positions.length).toBeGreaterThan(0) + expect(new Set(positions).size).toBe(1) + } finally { + release.resolve() + await observation.dispose() + } +}) diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index b1c7e473519..2fe2c063595 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -88,8 +88,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) { if (value.mode === "normal" && !command) { session.handoff?.set(handoffMessage(value)) const optimisticBusy = !input.adapter.working() - if (optimisticBusy) session.data.session.setStatus(session.id, "running") - const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then( + if (optimisticBusy && input.adapter.kind === "new-session") + session.data.session.setStatus(session.id, "running") + const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => { + if (optimisticBusy && input.adapter.kind === "active-session") + session.data.session.setStatus(session.id, "running") + }).then( () => ({ ok: true as const }), (error) => ({ ok: false as const, error }), ) @@ -359,7 +363,8 @@ async function applySelection( async function sendPrompt( session: ComposerSession, value: ComposerSubmission, - track?: ModelSelection["trackSessionCommit"], + track: ModelSelection["trackSessionCommit"] | undefined, + onAdmit: () => void, ) { const request = await buildSubmissionRequest(session, value) // Switching agent or model reconfigures the session immediately, and with it @@ -389,7 +394,9 @@ async function sendPrompt( }, }, } - await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission)) + const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission)) + onAdmit() + await sending } async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) { diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index e924d346892..c09dc0c10e0 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -515,6 +515,19 @@ export function createTimelineVirtualizer(input: Input) {
{ element = value + if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending) + return + // The optimistic row can paint before ResizeObserver corrects the tail estimates. + // Measure the mounted tail and pin it in this render's microtask instead. + queueMicrotask(() => { + if (!input.pinned() || !virtualContent?.isConnected) return + virtualizer.elementsCache.forEach((item) => { + if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight) + }) + virtualizer.resizeItem(item().index, element.offsetHeight) + virtualContent.style.height = `${virtualizer.getTotalSize()}px` + virtualizer.scrollToEnd() + }) }} data-index={item().index} style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}