From d68879672e7c2c42f3e2b4384cbc2ee4ac464211 Mon Sep 17 00:00:00 2001 From: Sam Heutmaker Date: Fri, 16 Oct 2026 20:00:24 -0700 Subject: [PATCH 1/3] fix(web): let latest navigation supersede pending anchor restoration --- .github/workflows/ci.yml | 8 ++++++++ apps/web/scripts/chat-activity.mjs | 7 ++++++- apps/web/scripts/history-gap.mjs | 17 +++++++++-------- apps/web/scripts/repl-viewer.mjs | 25 +++++++++++++++++-------- apps/web/scripts/settings.mjs | 14 +++++++++----- docs/frontend.md | 4 +++- docs/release-readiness.md | 16 ++++++++++++++++ packages/app/src/reading-list.tsx | 3 +++ 8 files changed, 71 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76c17679f..bcc3215e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,11 @@ jobs: - run: node packages/ui/tests/csp.mjs - run: npm run test:packed -w @whip/ui - run: WHIP_WEB_BROWSERS=chromium,firefox node apps/web/scripts/browser.mjs + - name: REPL, provider settings, and child-agent acceptance + run: | + node apps/web/scripts/repl-viewer.mjs + node apps/web/scripts/settings.mjs + WHIP_CHAT_AGENTS_ONLY=1 node apps/web/scripts/chat-activity.mjs - run: node apps/web/scripts/model-picker.mjs - run: node apps/web/scripts/snapshot-refresh.mjs - run: node --test apps/web/scripts/performance-probes.test.mjs @@ -247,6 +252,9 @@ jobs: path: | /tmp/whip-web-browser-results /tmp/whip-web-snapshot-refresh + /tmp/whip-repl-viewer-results + /tmp/whip-settings-browser-results + /tmp/whip-chat-activity-results packages/ui/ui-test-results docs: diff --git a/apps/web/scripts/chat-activity.mjs b/apps/web/scripts/chat-activity.mjs index 7d71512ce..b970e5fda 100644 --- a/apps/web/scripts/chat-activity.mjs +++ b/apps/web/scripts/chat-activity.mjs @@ -230,7 +230,12 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' await page.goBack(); await expect(dock.getByRole('status')).toHaveText('Waiting for agents'); await expect(group).toHaveCount(1); - await dock.getByRole('button', { name: 'Pause activity animation' }).click(); + // Motion is now owned by Appearance settings, not the activity status button. + await page.locator('#whip-settings-link').click(); + await page.getByRole('navigation', { name: 'Settings categories', exact: true }).getByRole('button', { name: 'Appearance', exact: true }).click(); + await page.getByRole('combobox', { name: 'Reduce motion', exact: true }).click(); + await page.getByRole('option', { name: 'Reduce', exact: true }).click(); + await page.getByRole('button', { name: 'Back to workspace', exact: true }).click(); await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduce'); await expect(dock.locator('[data-activity-animation]')).toHaveAttribute('data-activity-animation', 'static'); await expect(working.locator('[data-activity-animation]')).toHaveAttribute('data-activity-animation', 'static'); diff --git a/apps/web/scripts/history-gap.mjs b/apps/web/scripts/history-gap.mjs index 0d2466a74..84a8352e3 100644 --- a/apps/web/scripts/history-gap.mjs +++ b/apps/web/scripts/history-gap.mjs @@ -33,17 +33,18 @@ export async function checkHistoryRecovery({ page, client, fixture, root, direct const session = client.session(root); const view = createSessionView(session); await view.start(); - const findGap = async () => { - await reading.hover(); await page.mouse.wheel(0, -1); + const findGap = async (region = reading) => { + await region.hover(); await page.mouse.wheel(0, -1); await page.waitForTimeout(200); - await reading.evaluate(element => { element.dispatchEvent(new Event('scrollend')); element.scrollTop = 0; }); - for (let attempt = 0; attempt < 100; attempt++) { - const gap = reading.locator('[data-history-gap]').first(); - const visible = await reading.evaluate(element => { + await region.evaluate(element => { element.dispatchEvent(new Event('scrollend')); element.scrollTop = 0; }); + // A bounded 512-record window can exceed 100 viewports of code/output. + for (let attempt = 0; attempt < 512; attempt++) { + const gap = region.locator('[data-history-gap]').first(); + const visible = await region.evaluate(element => { const gap = element.querySelector('[data-history-gap]'); if (!gap) { element.scrollTop += element.clientHeight * 0.6; return false; } const top = gap.getBoundingClientRect().top - element.getBoundingClientRect().top; - if (top >= 0 && top < element.clientHeight - 100) return true; + if (top >= 0 && top + gap.getBoundingClientRect().height <= element.clientHeight) return true; element.scrollTop += top - 100; return false; }); if (visible) return gap; @@ -106,7 +107,7 @@ export async function checkHistoryRecovery({ page, client, fixture, root, direct const readsBeforeSwitch = requests.length; await page.getByRole('button', { name: 'REPL', exact: true }).click(); const notebook = page.getByRole('region', { name: 'REPL executions', exact: true }); - await expect(notebook.locator('[data-history-gap]')).toContainText("Couldn't load messages"); + await expect(await findGap(notebook)).toContainText("Couldn't load messages"); await page.goBack(); await findGap(); assert.equal(requests.length, readsBeforeSwitch, 'Chat and REPL share the failed range without refetching'); const retry = control.getByRole('button', { name: 'Retry', exact: true }); diff --git a/apps/web/scripts/repl-viewer.mjs b/apps/web/scripts/repl-viewer.mjs index e1801d019..12e6358cc 100644 --- a/apps/web/scripts/repl-viewer.mjs +++ b/apps/web/scripts/repl-viewer.mjs @@ -58,6 +58,7 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' const ready = async (id, mode) => { await (mode === 'repl' ? notebook(id) : mode === 'trace' ? panel(id).locator('[data-session-view="trace"]') : panel(id).getByLabel('Message WHIP', { exact: true })).waitFor(); await expect(page.locator('html')).toHaveAttribute('data-theme', /claude-code|light|dark/); + await expect(page.locator('[data-startup-phase="visible"]')).toBeVisible(); }; const action = async (id, label) => { await page.locator(`[data-workspace-tab="${id}"]`).getByRole('tab').click({ button: 'right' }); @@ -81,21 +82,24 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' const backwardReads = agentId => frames.filter(frame => frame.method === 'history.page' && frame.params.agent_id === agentId && frame.params.before_seq > 0); const scrollPage = async (region, agentId) => { const count = backwardReads(agentId).length; - await region.dispatchEvent('wheel'); + const threshold = await region.evaluate(element => element.getAttribute('aria-label') === 'Conversation' ? Math.min(2400, Math.max(800, 2 * element.clientHeight)) : 256); + const start = threshold + 100; + await region.dispatchEvent('wheel', { deltaY: -1 }); await eventually(async () => { - // Newly prepended rows are measured lazily when this position is visited. - await region.evaluate(element => { element.scrollTop = 350; }); + // Stay near the actual threshold so the next visible anchor is mounted. + await region.evaluate((element, offset) => { element.scrollTop = offset; }, start); await region.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - return Math.abs((await region.evaluate(element => element.scrollTop)) - 350) < 1 && await anchor(region); + return Math.abs((await region.evaluate(element => element.scrollTop)) - start) < 1 && await anchor(region); }, { description: 'settled position above pagination threshold' }); // Scroll the real viewport through the threshold. Capture the visible row in // the same task, before the scroll handler can start its asynchronous read. - const before = await region.evaluate(element => { - element.scrollTop = 200; + const before = await region.evaluate((element, offset) => { + element.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -1 })); + element.scrollTop = offset; const top = element.getBoundingClientRect().top; const row = [...element.querySelectorAll('[data-reading-id]')].find(item => item.getBoundingClientRect().bottom > top); return row ? { id: row.dataset.readingId, offset: row.getBoundingClientRect().top - top } : null; - }); + }, threshold - 50); assert.ok(before, 'No visible row before pagination'); await eventually(() => { const reads = backwardReads(agentId); @@ -124,8 +128,12 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' }; // The last cell's ordinal is the loaded count; the REPL has no summary toolbar. const loadedCells = async id => { - await latest(id); const last = notebook(id).locator('[data-repl-cell]').last(); + await last.waitFor({ state: 'attached' }); + // Loading a sparse page can remove Latest while a click is waiting for it. + // Count the loaded rows by scrolling the viewport after cells arrive. + await notebook(id).evaluate(element => { element.scrollTop = element.scrollHeight; }); + await notebook(id).evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await expect(last).toBeVisible(); return Number((await last.getAttribute('aria-label')).match(/^Execution (\d+)$/)[1]); }; @@ -350,6 +358,7 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' await latest(moved); const live = notebook(moved).locator('[data-repl-cell]').filter({ hasText: 'live' }).last(); await expect(live).toContainText('Writing'); + await expect(live).toBeInViewport(); await expect(live).toContainText('print("live'); await step('progress'); await expect(live).toContainText('Running'); diff --git a/apps/web/scripts/settings.mjs b/apps/web/scripts/settings.mjs index 14892cc92..8832f4702 100644 --- a/apps/web/scripts/settings.mjs +++ b/apps/web/scripts/settings.mjs @@ -5,9 +5,6 @@ import { join } from 'node:path'; import { chromium, firefox, expect } from '@playwright/test'; import { startFixture } from '../../../packages/sdk/scripts/fixture.mjs'; -process.env.OPENROUTER_API_KEY = 'fixture-settings-key'; -process.env.INFERENCE_API_KEY = ''; - const results = process.env.WHIP_SETTINGS_RESULTS ?? '/tmp/whip-settings-browser-results'; await mkdir(results, { recursive: true }); const axeSource = await readFile(createRequire(import.meta.url).resolve('axe-core/axe.min.js'), 'utf8'); @@ -38,8 +35,15 @@ for (const engine of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split await page.getByRole('option', { name: option, exact: true }).click(); }; try { - await writeFile(join(fixture.directory, 'home', 'config.json'), JSON.stringify({ defaultModel: 'settings-model', defaultProvider: 'openrouter', models: { 'settings-model': { providers: ['openrouter'] }, 'unsaved-settings-model': { providers: ['openrouter'] } } })); - await writeFile(join(fixture.directory, 'home', 'models.json'), JSON.stringify({ openrouter: { baseUrl: 'https://openrouter.ai/api/v1', fetchedAt: new Date().toISOString(), models: [{ id: 'settings-model' }, { id: 'unsaved-settings-model', reasoning_efforts: ['low', 'high'] }] } })); + // TestMain intentionally clears inherited provider keys. Configure a local + // fixture route explicitly so this acceptance test never needs an account. + const providerBaseURL = fixture.info.frontend + '/v1'; + await writeFile(join(fixture.directory, 'home', 'config.json'), JSON.stringify({ + defaultModel: 'settings-model', defaultProvider: 'openrouter', + providers: { openrouter: { baseUrl: providerBaseURL, api: 'openai-completions', auth: 'none' } }, + models: { 'settings-model': { providers: ['openrouter'] }, 'unsaved-settings-model': { providers: ['openrouter'] } }, + })); + await writeFile(join(fixture.directory, 'home', 'models.json'), JSON.stringify({ openrouter: { baseUrl: providerBaseURL, fetchedAt: new Date().toISOString(), models: [{ id: 'settings-model' }, { id: 'unsaved-settings-model', reasoningEfforts: ['low', 'high'] }] } })); await page.goto(chatURL); const composer = page.locator('[data-whip-composer]'); await composer.waitFor(); diff --git a/docs/frontend.md b/docs/frontend.md index ec9b05cfa..7a0968cb2 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -2106,7 +2106,9 @@ reading anchors; it does not undo filesystem effects. TanStack Virtual bounds rendered rows. Follow its measurement/anchor integration when changing variable-height Markdown, code, or tool output. Preserve the reader's position while paging or streaming; show Jump to latest when away from -live output. A replaced/evicted anchor should degrade visibly to retained history, +live output. An explicit Jump to latest supersedes older reading-position restores, +including an insertion-anchor restore started while its history fetch is pending. +A replaced/evicted anchor should degrade visibly to retained history, not fetch unlimited history to recover an exact pixel. Code rendering is lazy, bounded, copyable text; do not inject untrusted HTML or treat model output as UI instructions. Keep URL/content checks in the existing rendering paths. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 571976169..017a6be29 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -46,3 +46,19 @@ the compatibility lifecycle/rollback scenarios; verify the shared renderer's REPL, agent, provider and effort controls; and run the staged desktop lifecycle smoke against temporary homes. These checks use local fixture providers and must not consume personal provider credentials or existing sessions. + +## Follow-up renderer acceptance repair + +The release smoke exposed an existing `ReadingList` race: after Latest fetched +newer history, an insertion-anchor restore created during that fetch could pull +the REPL back to older rows. The separate renderer fix cancels that restore after +the read completes, while preserving the existing user-input cancellation guard. +No SDK, protocol, provider, or persistence semantics change. + +The focused REPL, settings, and child-agent renderer checks run in the SDK CI +job on Chromium and Firefox, with their artifacts retained alongside browser +acceptance evidence. Their setup waits for startup to finish, exercises an upward +scroll from outside both history-prefetch thresholds, and uses an explicit local +provider route instead of inherited account keys. The activity test changes +reduced motion through the current Appearance controls. These test-harness +repairs retain the existing behavioral assertions. diff --git a/packages/app/src/reading-list.tsx b/packages/app/src/reading-list.tsx index 0a3d1c8b7..27ff21658 100644 --- a/packages/app/src/reading-list.tsx +++ b/packages/app/src/reading-list.tsx @@ -492,6 +492,9 @@ export function ReadingList({ finally { setLoadingLatest(false); } if (request !== latestIntent.current || !viewport.current) return; } + // The history read can start a new insertion-anchor restore while awaited. + // The explicit Latest action supersedes that older reading position. + stopRestore(); intent.current = undefined; const root = viewport.current; jumping.current = animate && chatFollow && motion && !!root && root.scrollHeight - root.scrollTop - root.clientHeight > 2; From ddca8e62e9b149683217e123705be0da2c097c5d Mon Sep 17 00:00:00 2001 From: Sam Heutmaker Date: Fri, 16 Oct 2026 20:04:00 -0700 Subject: [PATCH 2/3] test(web): isolate activity and history recovery acceptance fixtures --- .github/workflows/ci.yml | 6 ++++-- apps/web/scripts/chat-activity.mjs | 3 ++- docs/release-readiness.md | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcc3215e7..b00d272ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,11 +225,13 @@ jobs: - run: node packages/ui/tests/csp.mjs - run: npm run test:packed -w @whip/ui - run: WHIP_WEB_BROWSERS=chromium,firefox node apps/web/scripts/browser.mjs - - name: REPL, provider settings, and child-agent acceptance + - name: Renderer release acceptance run: | node apps/web/scripts/repl-viewer.mjs node apps/web/scripts/settings.mjs - WHIP_CHAT_AGENTS_ONLY=1 node apps/web/scripts/chat-activity.mjs + WHIP_CHAT_AGENTS_ONLY=1 WHIP_CHAT_ACTIVITY_RESULTS=/tmp/whip-chat-activity-results/agents node apps/web/scripts/chat-activity.mjs + WHIP_CHAT_ACTIVITY_RESULTS=/tmp/whip-chat-activity-results/activity node apps/web/scripts/chat-activity.mjs + WHIP_CHAT_HISTORY_ONLY=1 WHIP_CHAT_ACTIVITY_RESULTS=/tmp/whip-chat-activity-results/history node apps/web/scripts/chat-activity.mjs - run: node apps/web/scripts/model-picker.mjs - run: node apps/web/scripts/snapshot-refresh.mjs - run: node --test apps/web/scripts/performance-probes.test.mjs diff --git a/apps/web/scripts/chat-activity.mjs b/apps/web/scripts/chat-activity.mjs index b970e5fda..c3eb09e88 100644 --- a/apps/web/scripts/chat-activity.mjs +++ b/apps/web/scripts/chat-activity.mjs @@ -458,7 +458,8 @@ for (const name of (process.env.WHIP_WEB_BROWSERS ?? 'chromium,firefox').split(' await expect(notebook).toContainText('Review complete.'); await page.goBack(); await expect(reading).toBeVisible(); - await checkHistoryRecovery({ page, client, fixture, root, directory, name }); + // History recovery runs through WHIP_CHAT_HISTORY_ONLY in its own fixture. + // This scenario's retained activity can evict the interior gap it exercises. // Normal scrolling must leave the activity dock available without extra subscriptions. await reading.evaluate(element => { element.scrollTop = 300; }); await expect(page.getByRole('textbox', { name: 'Message WHIP', exact: true })).toBeInViewport(); diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 017a6be29..b44488431 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -55,10 +55,15 @@ the REPL back to older rows. The separate renderer fix cancels that restore afte the read completes, while preserving the existing user-input cancellation guard. No SDK, protocol, provider, or persistence semantics change. -The focused REPL, settings, and child-agent renderer checks run in the SDK CI -job on Chromium and Firefox, with their artifacts retained alongside browser +The REPL, settings, child-agent, activity, and history-recovery checks run in SDK CI +on Chromium and Firefox, with their artifacts retained alongside browser acceptance evidence. Their setup waits for startup to finish, exercises an upward scroll from outside both history-prefetch thresholds, and uses an explicit local provider route instead of inherited account keys. The activity test changes reduced motion through the current Appearance controls. These test-harness repairs retain the existing behavioral assertions. + +Activity and history recovery each use their existing standalone mode with a +fresh fixture. Reusing the activity stress fixture can evict the interior range +the recovery scenario expects to retain; the independent runs keep all of the +existing recovery, request-bound, identity, and reading-anchor assertions. From 7c8b15c68f54d3b3e778c140423fd87171c8e597 Mon Sep 17 00:00:00 2001 From: Sam Heutmaker Date: Fri, 16 Oct 2026 20:05:06 -0700 Subject: [PATCH 3/3] ci: validate UI repair after retargeting to development