Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +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: Renderer release acceptance
run: |
node apps/web/scripts/repl-viewer.mjs
node apps/web/scripts/settings.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
Expand All @@ -247,6 +254,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:
Expand Down
10 changes: 8 additions & 2 deletions apps/web/scripts/chat-activity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -453,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();
Expand Down
17 changes: 9 additions & 8 deletions apps/web/scripts/history-gap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
25 changes: 17 additions & 8 deletions apps/web/scripts/repl-viewer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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);
Expand Down Expand Up @@ -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]);
};
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 9 additions & 5 deletions apps/web/scripts/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,24 @@ 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 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.
3 changes: 3 additions & 0 deletions packages/app/src/reading-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,9 @@ export function ReadingList<Row extends { id: string; seq?: number }>({
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;
Expand Down
Loading