Skip to content

Commit 76096bc

Browse files
bloveclaude
andcommitted
fix(examples/chat): stage pin re-arms, stills look beats up by name, e2e guards the pinned viewport
The transcript pin scheduled one two-frame pair per seek and dropped any publish that landed while that pair was in flight, so content rendering after the pending pair's inner frame could stay below the fold. pinTranscript now keeps one pair in flight at a time and a publish during the pair re-arms exactly one more pair after it (pinPending). The destroy hook still cancels whichever frame is pending and clears the flag, so nothing runs after destroy. The docblock now also states that this deliberately overrides the chat's unpin-on-user-scroll for a scrubbed display surface. The pin still reaches into the chat's private .chat-scroll container. The proper fix is a public scrollToBottom() on ChatComponent (today the protected onScrollBubbleClick); that touches libs/ and is deferred, recorded as a TODO at the call site. The unit spec polls frame by frame instead of hard-coding "two frames deep", and gains a re-arm case whose scrollHeight grows after the first pair's inner frame. Mutation-checked: removing the re-arm fails only the new case; removing the publish() call fails both. The still recorder looks beats up by name (StageBeat) rather than by index, collects oversized files and asserts once after both loops so a failure cannot leave a half-rewritten set, and its header says deviceScaleFactor 2 applies to both sizes. stage.spec.ts asserts the interrupt panel and the A2UI surface are in the viewport, guarding the pin end to end, and explains the +1 on the hold boundary. The shared window augmentation moves to e2e/stage-globals.d.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 60eb90c commit 76096bc

6 files changed

Lines changed: 107 additions & 33 deletions

File tree

2 Bytes
Loading

examples/chat/angular/e2e/record-stage-stills.record.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,17 @@
1414
* the devtools docked right. Below 768px the stage renders NO devtools (see
1515
* `readStageDock` — the phone path is chat only, a docked panel would eat the
1616
* transcript), so the phone still is the chat at 390x650 (3:5, the phone
17-
* ratio the hero poster uses), captured at deviceScaleFactor 2 so the shipped
18-
* 585-wide file is a crisp downscale rather than a 1.5x upscale of a 390px
19-
* raster.
17+
* ratio the hero poster uses). `deviceScaleFactor: 2` applies to BOTH sizes:
18+
* the desktop still is a 2400-wide raster downscaled to 1200, and the phone
19+
* still a 780-wide raster downscaled to 585 — crisp downscales rather than a
20+
* 1x raster shipped as-is or a 1.5x upscale of a 390px one.
2021
*/
2122
import { expect, test } from '@playwright/test';
2223
import { resolve } from 'node:path';
2324
import sharp from 'sharp';
24-
import type { StageState } from '../src/app/stage/stage-bridge';
25+
import type { StageBeat } from '../src/app/stage/stage-recording.types';
2526
import type { StageTimeline } from '../src/app/stage/stage-timeline';
2627

27-
// Mirrors the augmentation in stage-mode.component.ts, which the e2e tsconfig does not pull in.
28-
declare global {
29-
interface Window {
30-
__stageTimeline?: StageTimeline;
31-
__stageApplied?: StageState;
32-
}
33-
}
34-
3528
const OUT_DIR = resolve(__dirname, '../../../../apps/website/public/screenshots');
3629
const SIZES = [
3730
{ suffix: '', width: 1200, height: 720, ship: 1200 },
@@ -46,12 +39,20 @@ test('capture stage stills', async ({ page }) => {
4639
await page.goto('/stage?t=0');
4740
await page.waitForFunction(() => !!window.__stageTimeline);
4841
const tl = await page.evaluate(() => window.__stageTimeline as StageTimeline);
42+
const endOf = (b: StageBeat) => {
43+
const hit = tl.beats.find((x) => x.beat === b);
44+
if (!hit) throw new Error(`recording has no "${b}" beat`);
45+
return hit.endMs;
46+
};
4947
const settle: Record<string, number> = {
50-
stream: tl.beats[0].endMs,
51-
persist: tl.beats[1].endMs,
48+
stream: endOf('stream'),
49+
persist: endOf('persist'),
5250
approve: tl.hold.startMs + Math.round((tl.hold.endMs - tl.hold.startMs) / 2),
5351
render: tl.totalMs,
5452
};
53+
// Collected, not asserted per file: a throw mid-loop would leave the set
54+
// half rewritten. Every offender is listed once after both loops.
55+
const oversized: string[] = [];
5556
for (const size of SIZES) {
5657
await page.setViewportSize({ width: size.width, height: size.height });
5758
for (const [beat, t] of Object.entries(settle)) {
@@ -75,8 +76,9 @@ test('capture stage stills', async ({ page }) => {
7576
.resize({ width: size.ship })
7677
.webp({ quality: 60, effort: 6 })
7778
.toFile(out);
78-
expect(info.size, `${out} exceeds ${MAX_BYTES} bytes`).toBeLessThanOrEqual(MAX_BYTES);
79+
if (info.size > MAX_BYTES) oversized.push(`${out} (${info.size} bytes)`);
7980
console.log(`wrote ${out} (${Math.round(info.size / 1024)} KB)`);
8081
}
8182
}
83+
expect(oversized, `stills over ${MAX_BYTES} bytes:\n${oversized.join('\n')}`).toEqual([]);
8284
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { StageState } from '../src/app/stage/stage-bridge';
2+
import type { StageTimeline } from '../src/app/stage/stage-timeline';
3+
4+
// Mirrors the augmentation in stage-mode.component.ts: the e2e tsconfig does
5+
// not compile src/, so the stage specs and the still recorder share this copy.
6+
declare global {
7+
interface Window {
8+
__stageTimeline?: StageTimeline;
9+
__stageApplied?: StageState;
10+
}
11+
}

examples/chat/angular/e2e/stage.spec.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
import { test, expect, type Page } from '@playwright/test';
22
import { attachBrowserHygiene } from './test-helpers';
3-
import type { StageState } from '../src/app/stage/stage-bridge';
43
import type { StageTimeline } from '../src/app/stage/stage-timeline';
54

6-
// Mirrors the augmentation in stage-mode.component.ts, which the e2e tsconfig does not pull in.
7-
declare global {
8-
interface Window {
9-
__stageTimeline?: StageTimeline;
10-
__stageApplied?: StageState;
11-
}
12-
}
13-
145
/**
156
* The /stage route replays the committed `public/stage-replay.json` beside the
167
* real devtools, seekable by `?t=<ms>`. The page publishes its timeline on
@@ -33,8 +24,12 @@ test.describe('stage replay', () => {
3324
const hygiene = attachBrowserHygiene(page);
3425
const tl = await timeline(page);
3526
await expect(page.getByRole('region', { name: 'Chat devtools' })).toBeVisible();
27+
// +1: strictly inside the hold. The boundary instant still belongs to the
28+
// outgoing run (phaseReachedAt in stage-timeline.ts renders t minus an epsilon).
3629
await page.goto(`/stage?t=${tl.hold.startMs + 1}`);
3730
await expect(page.locator('chat-interrupt-panel')).toBeAttached({ timeout: 60_000 });
31+
// Guards the transcript pin: the panel and the newest content sit in view.
32+
await expect(page.locator('chat-interrupt-panel')).toBeInViewport({ timeout: 60_000 });
3833
// The pause comes from delete_backups, after list_backups has rendered its
3934
// registered tool view — the inventory the visitor is being asked about.
4035
await expect(page.locator('app-backup-table [data-state="rows"]')).toBeAttached();
@@ -55,6 +50,8 @@ test.describe('stage replay', () => {
5550
const tl = await timeline(page);
5651
await page.goto(`/stage?t=${tl.totalMs}`);
5752
await expect(page.locator('a2ui-surface').first()).toBeAttached({ timeout: 90_000 });
53+
// Guards the transcript pin: the generated form is the newest content.
54+
await expect(page.locator('a2ui-surface').first()).toBeInViewport({ timeout: 90_000 });
5855
await expect(page.locator('chat-interrupt-panel')).toHaveCount(0);
5956
await page.getByRole('tab', { name: 'Timeline' }).click();
6057
await expect(page.getByRole('region', { name: 'Chat devtools' })).toContainText(/checkpoint/i);

examples/chat/angular/src/app/stage/stage-mode.component.spec.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,57 @@ describe('StageMode', () => {
3636
expect(window.__stageApplied?.t).toBe(25);
3737
});
3838

39-
it('pins the transcript to its newest content once a seek settles', async () => {
39+
/** The chat's scroll container, with jsdom's zero layout replaced by a tall transcript. */
40+
async function bootWithScroller(): Promise<HTMLElement> {
4041
// The chat shows its welcome screen until a message lands, so the scroll
4142
// container only exists once the first run has been applied.
4243
await fx.componentInstance.boot(new URLSearchParams('t=0'));
4344
fx.detectChanges();
4445
const scroller = (fx.nativeElement as HTMLElement).querySelector<HTMLElement>('chat .chat-scroll');
45-
expect(scroller).toBeTruthy();
46+
if (!scroller) throw new Error('chat .chat-scroll did not render');
4647
// jsdom lays nothing out: stand in for a transcript taller than its box.
47-
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 2400 });
48+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 2400 });
4849
Object.defineProperty(scroller, 'scrollTop', { configurable: true, writable: true, value: 0 });
50+
return scroller;
51+
}
52+
53+
const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
54+
55+
/** Polls frame by frame so the test pins the BEHAVIOR, not how many frames deep it lands. */
56+
async function pinnedWithin(scroller: HTMLElement, frames: number): Promise<boolean> {
57+
for (let i = 0; i < frames; i++) {
58+
if (scroller.scrollTop === scroller.scrollHeight) return true;
59+
await frame();
60+
}
61+
return scroller.scrollTop === scroller.scrollHeight;
62+
}
63+
64+
it('pins the transcript to its newest content once a seek settles', async () => {
65+
const scroller = await bootWithScroller();
4966
await fx.componentInstance.controller()?.seek(25);
5067
fx.detectChanges();
51-
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
52-
expect(scroller?.scrollTop).toBe(2400);
68+
expect(await pinnedWithin(scroller, 6)).toBe(true);
69+
expect(scroller.scrollTop).toBe(2400);
70+
});
71+
72+
it('re-arms one more pin when a publish lands while a pair is in flight', async () => {
73+
const scroller = await bootWithScroller();
74+
const c = fx.componentInstance.controller();
75+
if (!c) throw new Error('no controller');
76+
// The first publish schedules the pair; the second lands while that pair
77+
// is still in flight, so it must re-arm one more pair rather than be dropped.
78+
await c.seek(25);
79+
fx.detectChanges();
80+
await c.seek(30);
81+
fx.detectChanges();
82+
// The content that grows AFTER the first pair's inner frame: the two-frame
83+
// pair alone would pin at 2400 and leave the new 1200px below the fold.
84+
await frame();
85+
await frame();
86+
expect(scroller.scrollTop).toBe(2400);
87+
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, writable: true, value: 3600 });
88+
expect(await pinnedWithin(scroller, 6)).toBe(true);
89+
expect(scroller.scrollTop).toBe(3600);
5390
});
5491

5592
it('posts ready and state through the bridge', async () => {

examples/chat/angular/src/app/stage/stage-mode.component.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ export class StageMode {
297297
private seekTarget: number | null = null;
298298
private seekFrame: number | null = null;
299299
private pinFrame: number | null = null;
300+
/** A publish landed while a pin pair was in flight; one more pair follows it. */
301+
private pinPending = false;
300302

301303
constructor() {
302304
this.watchDock();
@@ -307,6 +309,7 @@ export class StageMode {
307309
}
308310
this.seekFrame = null;
309311
this.pinFrame = null;
312+
this.pinPending = false;
310313
});
311314
afterNextRender(() => {
312315
if (StageMode.autoBoot) {
@@ -419,19 +422,43 @@ export class StageMode {
419422
* (the backup table), the A2UI surface, and the interrupt panel — which sits
420423
* above the chat and shrinks it — have rendered, and the newest content ends
421424
* up below the fold: at the hold the transcript sat ~700px above its bottom.
422-
* Live token cadence hides the same gap. One frame per seek, last wins; two
423-
* frames deep so the views render in the first and layout settles in the
424-
* second. `.chat-scroll` is the chat's own scroll container
425+
* Live token cadence hides the same gap.
426+
*
427+
* Scheduling: one pair of frames is in flight at a time — the views render
428+
* in the first, layout settles in the second, and the scroll write lands at
429+
* the end of the second. A publish that arrives while a pair is in flight
430+
* re-arms exactly one more pair after it, so content that renders after
431+
* the pending pair's inner frame is still pinned rather than dropped.
432+
*
433+
* This deliberately overrides the chat's own unpin-on-user-scroll: the
434+
* stage is a scrubbed display surface, not a reading surface, so a viewer
435+
* who scrolls up is re-pinned on the next applied seek.
436+
*
437+
* `.chat-scroll` is the chat's own scroll container
425438
* (libs/chat/.../chat.component.ts, `#scrollContainer`); it exposes no
426439
* scroll API.
440+
* TODO: replace with a public scrollToBottom() on ChatComponent
441+
* (libs/chat/src/lib/compositions/chat/chat.component.ts, today protected
442+
* onScrollBubbleClick) so this stops depending on the private .chat-scroll
443+
* class.
427444
*/
428445
private pinTranscript(): void {
429-
if (typeof requestAnimationFrame !== 'function' || this.pinFrame !== null) return;
446+
if (typeof requestAnimationFrame !== 'function') return;
447+
if (this.pinFrame !== null) {
448+
this.pinPending = true;
449+
return;
450+
}
430451
this.pinFrame = requestAnimationFrame(() => {
452+
// The pair now owns every publish that arrived before this frame fired.
453+
this.pinPending = false;
431454
this.pinFrame = requestAnimationFrame(() => {
432455
this.pinFrame = null;
433456
const el = this.host.nativeElement.querySelector<HTMLElement>('chat .chat-scroll');
434457
if (el) el.scrollTop = el.scrollHeight;
458+
if (this.pinPending) {
459+
this.pinPending = false;
460+
this.pinTranscript();
461+
}
435462
});
436463
});
437464
}

0 commit comments

Comments
 (0)