From 809d2b156c0240d239e8f00dfe241adedc7efb5f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 18:11:20 +0800 Subject: [PATCH 1/5] test(desktop): rebuild transcript scrolling coverage as stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4741 removed transcript-scroll.spec.ts because it asserted scroll offsets and bounding boxes against a compositor settling on its own schedule, and blocked main while doing it. That left transcript scrolling with no automated coverage at all. The assertions need a real layout engine, not Electron, so they belong one tier down. Storybook renders in the same Chromium, settles per story rather than per application launch, runs its workers in parallel, and has nothing competing for OS focus. Nine of the eleven removed tests are rebuilt here as play functions: ChatView takes the transcript, the history seam and the growth signal as props, so a story reaches every one of them without a fake backend — the loader is hasOlderHistory plus onLoadEarlierHistory, and the streaming tail is a live Turn whose text grows per frame. Two are not rebuilt, and neither belongs at this tier: - the gesture a nested scroller consumed turns on Chromium's own scroll chaining, which needs real wheel input and stays in E2E; - the Session-switch anchor needs shell state app-shell.tsx still holds (#4582). The nested-scroller history case does sink: the guard it exercises reads composedPath() and the overflow of what the wheel crossed, which is DOM state, so a dispatched wheel takes the same branch a real one does. One threshold changed meaning rather than value. Earlier history landing above the reader was asserted within 4px; a Turn carries content-visibility: auto, so one that lands off screen is anchored against its estimated height and settles a stable 12px away. The budget is now a fraction of what arrived, which a reader who went with the history — moving by the whole insert — still fails. Refs #4761, #4727. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 523 ++++++++++++++++++++- 1 file changed, 522 insertions(+), 1 deletion(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index d82ab9389b..111e5c24e4 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -19,7 +19,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, waitFor } from 'storybook/test'; -import { useState, type CSSProperties, type ReactNode } from 'react'; +import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import type { ComponentProps } from 'react'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; @@ -1430,3 +1430,524 @@ export const GoalDialogOpen: Story = { ), }; + +/** + * Where the transcript is looking while an answer streams into it. + * + * This is the tier `transcript-scroll.spec.ts` was removed from in #4741: the + * assertions need a real layout engine, not Electron, and under a shared + * compositor they read a scroller mid-pin. Chromium settles per story here + * instead of per application launch. + * + * Positions are asserted against the scroller's own end, never as a pixel + * delta: a delta is satisfiable by two wrongs, where the content grew by as + * much as the view moved. + */ +const TAIL_SCROLLER = '[data-chat-scroll-container="true"]'; + +// Enough paragraphs to push the transcript past a viewport twice over, and +// few enough that the stream ends while the story is still settling — the +// final reading has to be taken against a transcript that stopped growing. +const TAIL_LINES = Array.from( + { length: 60 }, + (_, index) => `第 ${index} 行:这一段用来把转录推过滚动视口的高度。`, +); + +function tailScroller(): HTMLElement { + const root = document.querySelector(TAIL_SCROLLER); + if (!root) throw new Error('the chat scroll container is missing'); + return root; +} + +/** The distance to the tail plus the three numbers it came from. */ +function tailMetrics(): { + distance: number; + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} { + const root = tailScroller(); + return { + distance: Math.round(root.scrollHeight - root.scrollTop - root.clientHeight), + scrollTop: Math.round(root.scrollTop), + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + }; +} + +/** + * Samples every frame while the answer grows. The failure this guards against + * is the tail slipping away *while* content arrives, which a single reading + * afterwards cannot tell apart from a view dragged back at the last delta. + * Stops on the content rather than on a frame count. + */ +function measureTailLag(frameBudget: number): Promise<{ + worstLag: number; + worstFrameGrowth: number; + grewBy: number; + viewportHeight: number; +}> { + return new Promise((resolve) => { + const root = tailScroller(); + const startedAt = root.scrollHeight; + let previousScrollHeight = startedAt; + let worstLag = 0; + let worstFrameGrowth = 0; + let left = frameBudget; + const tick = (): void => { + const settledTail = previousScrollHeight - root.clientHeight; + worstLag = Math.max(worstLag, Math.abs(root.scrollTop - settledTail)); + worstFrameGrowth = Math.max(worstFrameGrowth, root.scrollHeight - previousScrollHeight); + previousScrollHeight = root.scrollHeight; + const enough = root.scrollHeight - startedAt > root.clientHeight; + if (enough || --left <= 0) { + resolve({ + worstLag: Math.round(worstLag), + worstFrameGrowth: Math.round(worstFrameGrowth), + grewBy: Math.round(root.scrollHeight - startedAt), + viewportHeight: root.clientHeight, + }); + } else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +/** Waits out the frames a layout change needs to commit and paint. */ +function painted(frames = 3): Promise { + return new Promise((resolve) => { + const tick = (left: number): void => { + if (left <= 0) resolve(); + else requestAnimationFrame(() => tick(left - 1)); + }; + tick(frames); + }); +} + +function messageList(): HTMLElement { + const list = tailScroller().querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + return list; +} + +function turnTop(turnId: string): number { + const turn = document.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + if (!turn) throw new Error(`turn ${turnId} is not mounted`); + return Math.round(turn.getBoundingClientRect().top); +} + +function firstResidentTurnId(): string | null { + return document + .querySelector('[data-transcript-turn-id]') + ?.getAttribute('data-transcript-turn-id') ?? null; +} + +/** + * Whether the dock affordance is actually offered. It is always in the DOM — + * Astryx toggles opacity and pointer-events — so presence proves nothing and + * a visibility check passes on the transparent one. + */ +function dockButton(): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find((candidate) => + /底部|to bottom/i.test( + `${candidate.getAttribute('aria-label') ?? ''} ${candidate.textContent ?? ''}`, + ), + ); + if (!button) throw new Error('the scroll-to-bottom affordance is missing'); + return button; +} + +function dockOffered(): boolean { + const style = getComputedStyle(dockButton()); + return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5; +} + +/** + * A wheel the reader turned, delivered as an event rather than as input. + * + * The rule under test reads `composedPath()` and the overflow of what it + * crosses — DOM state, not compositor state — so a dispatched wheel exercises + * the same branch a real one does. What a dispatched wheel cannot do is scroll, + * which is why the cases below set `scrollTop` themselves where the reader's + * movement matters. Gestures that turn on Chromium's own scroll chaining stay + * in E2E. + */ +function wheelUp(target: Element): void { + target.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); +} + +/** A scroller inside the transcript, standing in for a tool-output box. */ +function injectNestedScroller(parent: Element, style: string): HTMLElement { + const box = document.createElement('div'); + box.dataset.nestedScroller = 'true'; + box.style.cssText = style; + const filler = document.createElement('div'); + filler.style.height = '2000px'; + box.append(filler); + parent.append(box); + // Away from both ends, so scrolling up inside it never reaches a boundary. + box.scrollTop = 600; + return box; +} + +/** Answered turns, oldest first. `index` may go negative as history loads. */ +function transcriptTurns(from: number, count: number): StoredMessage[] { + return Array.from({ length: count }, (_, offset) => { + const index = from + offset; + const turnId = `turn-scroll-${index}`; + return [ + user(`msg-scroll-${index}-u`, turnId, 500 - index * 2, `第 ${index} 轮:把转录推长一点。`), + assistant( + `msg-scroll-${index}-a`, + turnId, + 499 - index * 2, + TAIL_LINES.slice(0, 4).join('\n\n'), + ), + ]; + }).flat(); +} + +/** + * Streams one line per frame into a live Turn. The E2E original drove a fake + * backend echoing a 60-line prompt; what the assertion needs is growth past a + * viewport with the Turn still live, which the props express directly. + */ +function StreamingTailHarness() { + const [lines, setLines] = useState(1); + useEffect(() => { + const id = window.setInterval(() => { + setLines((count) => { + if (count >= TAIL_LINES.length) { + window.clearInterval(id); + return count; + } + return count + 1; + }); + }, 16); + return () => window.clearInterval(id); + }, []); + return ( + + ); +} + +export const StreamingTailFollow: Story = { + render: () => , + play: async () => { + const lag = await measureTailLag(1_200); + + // The samples have to have covered more than a viewport of real growth, or + // every reading above is a stationary transcript and proves nothing. + expect(lag.grewBy).toBeGreaterThan(lag.viewportHeight); + expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8); + + // Settle against a transcript that stopped growing, or the reading only + // says the sampler happened to catch a frame between deltas. + await waitFor(() => { + expect(tailScroller().textContent).toContain(TAIL_LINES[TAIL_LINES.length - 1]); + }); + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + }, +}; + +/** + * Set by the harness below so a play function can drive the props React owns. + * One story renders per page, so a module-level handle addresses exactly one + * mounted harness. + */ +let appendTurn: (() => void) | undefined; + +/** Every `onLoadEarlierHistory` the transcript asked for, anchor turn first. */ +const historyLoads: string[] = []; + +const HISTORY_BATCH = 4; + +// More batches than any story here consumes. A load chain stops on its own +// once a batch carries the reader past the band; running the history out +// instead retires the "earlier history" notice, and that removal is a height +// change above the reader with no arrival to explain it. +const HISTORY_BATCHES_AVAILABLE = 8; + +/** A settled transcript with a turn the play function can make arrive. */ +function SettledTranscriptHarness({ turns }: { turns: number }) { + const [extra, setExtra] = useState(0); + useEffect(() => { + appendTurn = () => setExtra((count) => count + 1); + return () => { + appendTurn = undefined; + }; + }, []); + return ; +} + +/** + * The history-loading seam as props, which is all it ever was: the shell hands + * ChatView `hasOlderHistory` and a loader, and the loader prepends. The E2E + * original reached the same two props through a paginating fake backend. + */ +function HistoryHarness({ turns }: { turns: number }) { + const [range, setRange] = useState({ from: 0, count: turns }); + useEffect(() => { + historyLoads.length = 0; + }, []); + return ( + -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, + onLoadEarlierHistory: (anchorTurnId) => { + historyLoads.push(anchorTurnId ?? '(none)'); + setRange((current) => ({ + from: current.from - HISTORY_BATCH, + count: current.count + HISTORY_BATCH, + })); + }, + }} + /> + ); +} + +/** The band inside which the transcript treats a reader move as asking. */ +function loadBand(): number { + return Math.max(640, tailScroller().clientHeight * 2); +} + +export const TailFollowsGrowthOutsideTurns: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + + const grown = document.createElement('div'); + grown.dataset.outsideTurnGrowth = 'true'; + grown.style.height = '600px'; + messageList().append(grown); + // Outside a wrapper is what makes this the uncovered path: growth inside + // one is what every other story here already exercises. + expect( + grown.closest('[data-transcript-turn-id]'), + 'the injected box landed inside a turn wrapper', + ).toBe(null); + + await painted(6); + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + }, +}; + +export const ReaderScrolledUpIsNotPulledBack: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + + root.scrollTop -= 500; + await painted(6); + const before = tailMetrics().distance; + expect(before, JSON.stringify(tailMetrics())).toBeGreaterThan(100); + await waitFor(() => expect(dockOffered()).toBe(true)); + + const anchorTurnId = document.querySelector('[data-turn-id]')?.dataset.turnId; + if (!anchorTurnId) throw new Error('the transcript has no mounted turn'); + const anchorTop = turnTop(anchorTurnId); + + appendTurn?.(); + await waitFor(() => expect(tailMetrics().distance).toBeGreaterThan(before)); + + // The turn the reader was on is still where it was. Everything that + // arrived, arrived below them. + expect(Math.abs(turnTop(anchorTurnId) - anchorTop)).toBeLessThanOrEqual(4); + }, +}; + +export const DockAffordanceReturnsToTail: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + + tailScroller().scrollTop = 0; + await painted(6); + // Offered at all is the assertion: with Astryx's scroll layer off, its + // `isScrolledUp` never updates again, so the stock button would stay + // transparent forever. This one reads Maka's pin. + await waitFor(() => expect(dockOffered()).toBe(true)); + + dockButton().click(); + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + expect(dockOffered()).toBe(false); + }, +}; + +export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { + render: () => , + play: async () => { + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.scrollTop, JSON.stringify(settled)).toBeLessThanOrEqual(loadBand()); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + + const nested = injectNestedScroller(messageList(), 'height:120px;overflow-y:auto'); + await painted(6); + historyLoads.length = 0; + + wheelUp(nested); + await painted(6); + // The gesture crossed a scroller that could act on it, so it was never the + // reader asking for what is above the transcript. + expect(historyLoads).toEqual([]); + expect(nested.scrollTop).toBe(600); + }, +}; + +export const TailFollowDoesNotAskForHistory: Story = { + render: () => , + play: async () => { + const before = firstResidentTurnId(); + // A transcript shorter than about three viewports has its tail inside the + // band that asks for earlier history, so "near the start" cannot mean the + // reader wants it. + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.scrollTop, JSON.stringify(settled)).toBeLessThanOrEqual(loadBand()); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + + await painted(12); + // Nothing arrived that the reader did not ask for. + expect(historyLoads).toEqual([]); + expect(firstResidentTurnId()).toBe(before); + }, +}; + +export const AWheelTheScrollerCannotActOnAsksForHistory: Story = { + render: () => , + play: async () => { + const before = firstResidentTurnId(); + await painted(6); + const settled = tailMetrics(); + // Too short to move: no scroll can follow the wheel, so the authority + // never learns the reader asked. The wheel itself has to carry it. + expect(settled.scrollHeight, JSON.stringify(settled)).toBeLessThanOrEqual( + settled.clientHeight, + ); + + wheelUp(tailScroller()); + await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + }, +}; + +export const EarlierHistoryLandsAboveTheReader: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + + // Just short of the band that asks for more, so the active range has + // painted turns around the reader before the load starts. Landing straight + // on zero leaves no visible turn above the load boundary to anchor on. + root.scrollTop = loadBand() + 400; + await painted(6); + const before = firstResidentTurnId(); + const heightBefore = root.scrollHeight; + historyLoads.length = 0; + + // The move that asks for earlier history and the reading of where the + // reader is, in one task. + root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); + const rootTop = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + ); + if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); + const anchor = { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; + wheelUp(root); + + await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await painted(6); + + // The turns that arrived went above the reader, and the reader did not go + // with them. Asserting the element rather than a `scrollTop` delta is the + // point: a compensation computed from `scrollHeight` satisfies the delta + // while putting the reader somewhere else entirely. + // + // Budgeted against what arrived rather than in fixed pixels. A Turn carries + // `content-visibility: auto`, so one that lands off screen is anchored + // against its estimated height and settles a few pixels away from it; a + // reader who went with the history instead moves by the whole insert. + await waitFor(() => { + const inserted = tailScroller().scrollHeight - heightBefore; + expect(inserted, JSON.stringify({ anchor, loads: historyLoads })).toBeGreaterThan(400); + expect( + Math.abs(turnTop(anchor.turnId) - anchor.top), + JSON.stringify({ anchor, inserted, now: turnTop(anchor.turnId), ...tailMetrics() }), + ).toBeLessThanOrEqual(Math.max(4, inserted * 0.02)); + }); + }, +}; + +export const HistoryAtTheTopStillLandsAboveTheReader: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + // Writing zero while the scroller is still at zero is a no-op, so require + // the initial pin to have provably moved before exercising the real one. + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(0); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }); + const before = firstResidentTurnId(); + + // The one position where the browser declines to anchor, and the one the + // wheel-to-load path puts the reader in. + root.scrollTop = 0; + wheelUp(root); + + await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await painted(6); + + // Anchoring resumes at an offset of one pixel, so the offset itself is the + // evidence: left at zero the browser holds the scroller at the top and + // every turn that arrives pushes the reader's content down the viewport. + expect(tailScroller().scrollTop).toBeGreaterThanOrEqual(1); + }, +}; From 54e8a93476570097a5da3b6f7a9e5dfe2fb27b20 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 18:44:01 +0800 Subject: [PATCH 2/5] test(desktop): assert transcript geometry through an upward traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuild in the previous commit is a verbatim port, and #4761 warns that porting the eleven tests as they stood reproduces a blind spot: every one of them watches content arriving at a reader who stays put. None watches a reader travelling up through content-visibility placeholders as those materialise. Measured, that traversal is not still. A Turn off screen is laid out at contain-intrinsic-block-size: auto 280px and swaps to its real height on the way past, so fifteen upward steps drift by up to 163px and the transcript converges about 8% shorter. That is the cost #4206 accepted rather than a regression, so the story asserts a bound instead of stillness: - no single step throws the reader past a whole Turn — 163px against a Turn of 293px; - the whole traversal stays within 15%. #4259 moves the boundaries inside the Turn and was measured there at 63%. The bound is what #4206 buys: one estimate to correct per Turn, so the correction scales with Turns crossed rather than with what is inside them. Also fixes the CI failure the previous commit shipped. StreamingTailFollow waited out a stream paced by setInterval, which left it on the edge of waitFor's default one-second window — passing locally, failing on the runner. The stream is paced by frames now, in step with the per-frame sampler, and the play function stops it once the growth the assertion needed is behind it. 3.0s to 2.1s, and 8.9s under an 8x CPU throttle. Refs #4761. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 156 ++++++++++++++++++--- 1 file changed, 137 insertions(+), 19 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 111e5c24e4..fdbae3708d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1607,6 +1607,9 @@ function transcriptTurns(from: number, count: number): StoredMessage[] { }).flat(); } +/** Stops the harness below, so the tail can be read against a settled transcript. */ +let stopTailStream: (() => void) | undefined; + /** * Streams one line per frame into a live Turn. The E2E original drove a fake * backend echoing a 60-line prompt; what the assertion needs is growth past a @@ -1615,16 +1618,26 @@ function transcriptTurns(from: number, count: number): StoredMessage[] { function StreamingTailHarness() { const [lines, setLines] = useState(1); useEffect(() => { - const id = window.setInterval(() => { - setLines((count) => { - if (count >= TAIL_LINES.length) { - window.clearInterval(id); - return count; - } - return count + 1; - }); - }, 16); - return () => window.clearInterval(id); + // Paced by frames, not by the clock. The sampler reads per frame too, so + // the two stay in step on a slow runner instead of the story taking + // proportionally longer than the machine it was sized on. + let frame = 0; + let stopped = false; + const tick = (): void => { + if (stopped) return; + setLines((count) => (count >= TAIL_LINES.length ? count : count + 1)); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + const stop = (): void => { + stopped = true; + cancelAnimationFrame(frame); + }; + stopTailStream = stop; + return () => { + stop(); + stopTailStream = undefined; + }; }, []); return ( , play: async () => { - const lag = await measureTailLag(1_200); + // A fuse, not a duration: the sampler stops on the content, after a few + // dozen frames. Sized to run out well inside the smoke's per-story budget, + // so a stalled stream fails saying so instead of timing the story out. + const lag = await measureTailLag(600); // The samples have to have covered more than a viewport of real growth, or // every reading above is a stationary transcript and proves nothing. @@ -1673,14 +1689,17 @@ export const StreamingTailFollow: Story = { expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8); // Settle against a transcript that stopped growing, or the reading only - // says the sampler happened to catch a frame between deltas. - await waitFor(() => { - expect(tailScroller().textContent).toContain(TAIL_LINES[TAIL_LINES.length - 1]); - }); - await waitFor(() => { - const settled = tailMetrics(); - expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); - }); + // says the sampler happened to catch a frame between deltas. Stopping it + // here rather than waiting the stream out keeps the story's length tied to + // the growth the assertion needed, which is already behind us. + stopTailStream?.(); + await waitFor( + () => { + const settled = tailMetrics(); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }, + { timeout: 5_000 }, + ); }, }; @@ -1924,6 +1943,105 @@ export const EarlierHistoryLandsAboveTheReader: Story = { }, }; +/** + * The reader going *up* through Turns that have never rendered. + * + * New coverage rather than a restoration (#4761): the removed specs asserted + * turn-level anchoring against arrivals and tail-following lag while pinned, + * and both watch content coming to a reader who stays put. Neither watches a + * reader travelling through `content-visibility` placeholders as those + * materialise, because #4206 gives each Turn one boundary and made that stable + * by construction. #4259 moves the boundaries inside the Turn, where an upward + * traversal was measured moving `scrollHeight` by 63%. + * + * What is asserted is a bound, not stillness. A Turn off screen is laid out at + * `contain-intrinsic-block-size: auto 280px` and swaps to its real height on + * the way past; unless every Turn happens to be 280px tall, travelling through + * them moves things by construction, and measured here it moves the transcript + * about 8%. That is the cost #4206 already accepted. The bound is what #4206 + * buys on top of it — one estimate to correct per Turn, so the correction + * stays proportional to how many Turns were crossed rather than to how much is + * inside them. #4259 puts several boundaries in each Turn, and its measured + * 63% is what this fails on. + */ +const TRAVERSAL_STEP = 700; + +/** What one Turn is worth, measured after everything has rendered once. */ +function medianTurnHeight(): number { + const heights = [...tailScroller().querySelectorAll('[data-turn-id]')] + .map((turn) => turn.getBoundingClientRect().height) + .sort((a, b) => a - b); + if (heights.length === 0) throw new Error('the transcript has no mounted turn'); + return heights[Math.floor(heights.length / 2)]; +} + +/** The first Turn whose box is still on screen, and where it starts. */ +function anchorInView(): { turnId: string; top: number } { + const root = tailScroller(); + const rootTop = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + ); + if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); + return { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; +} + +export const UpwardTraversalHoldsTurnGeometry: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + const heightBefore = root.scrollHeight; + expect( + heightBefore / root.clientHeight, + 'the transcript has to be deep enough to hold unrendered Turns', + ).toBeGreaterThan(6); + + const drifts: number[] = []; + let steps = 0; + while (root.scrollTop > 0 && steps < 40) { + const anchor = anchorInView(); + const scrollBefore = root.scrollTop; + root.scrollTop = Math.max(0, scrollBefore - TRAVERSAL_STEP); + await painted(4); + + // The reader moved by what the scroller actually moved, so the Turn under + // them comes down the viewport by that much plus whatever the estimates + // above them were off by. + const travelled = scrollBefore - root.scrollTop; + drifts.push(Math.round(turnTop(anchor.turnId) - (anchor.top + travelled))); + steps += 1; + } + expect(steps, 'the traversal has to have taken real steps').toBeGreaterThan(6); + + const worstDrift = Math.max(...drifts.map(Math.abs)); + const turnHeight = medianTurnHeight(); + // No single step throws the reader past a whole exchange. One Turn's worth + // of correction is the most one Turn can owe. + expect(worstDrift, `per-step drift: ${drifts.join(' ')} against a Turn of ${turnHeight}`) + .toBeLessThanOrEqual(turnHeight); + + // And over the whole traversal the corrections stay proportional to the + // Turns crossed. Measured at ~8% here; #4259's 63% is the failure this + // exists to catch. + const heightAfter = root.scrollHeight; + expect( + Math.abs(heightAfter - heightBefore) / heightBefore, + JSON.stringify({ heightBefore, heightAfter, steps, turnHeight }), + ).toBeLessThanOrEqual(0.15); + + // And the reader can still get back. + dockButton().click(); + await waitFor( + () => { + const settled = tailMetrics(); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + }, + { timeout: 5_000 }, + ); + }, +}; + export const HistoryAtTheTopStillLandsAboveTheReader: Story = { render: () => , play: async () => { From 967f07ec71a5243a53de630b0b798c6c9f8fa029 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 19:07:46 +0800 Subject: [PATCH 3/5] test(desktop): rebuild prompt rail coverage as stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4741 removed prompt-rail.spec.ts along with transcript-scroll.spec.ts, and for the same reason. This finishes #4761's P0 by putting its assertions in the same tier as the previous two commits: ten of the eleven, one waiting on shell state. That spec existed because the rail failed three times in a row the same way — the code kept working and the pixels stopped. #2161 pinned it against .maka-chat-shell while Astryx's ChatLayout owned the scroll container, so it laid out across the whole conversation and scrolled off screen. #2338 parked it under macOS's overlay scrollbar, which takes no layout space but still swallows the pointer, so every tick rendered and none could be clicked. #2580 moved the tick onto Astryx's Button, whose label span put the bar back into normal flow, and an inline box takes no width or height, so the bars computed to 0x0 and shipped invisible in 0.1.9 and 0.1.10. None of the three is visible to a static read of the CSS and none is reachable from jsdom. All three need a real scroller with a real transcript. None needs Electron. The 120-prompt seeded session turned out not to be needed either. ChatView reads two props: the transcript carries only the Host's bounded active range, and transcriptTurnIndex carries the remaining landmarks. So the rail gets its full 64 ticks against 10 mounted Turns, which is what production does. A tick for a Turn outside the range comes back out as onLoadTranscriptTurn, so the jump that used to look dead — the head not mounted, the fill changing scrollHeight under the tail-follow lock — is reachable by moving the range in the harness. Two things a green run here does not mean. PromptRailTickOwnsItsOwnHitBox guards #2338, and it is load-bearing on macOS only: Linux's in-flow scrollbar moves the content column left instead of overlaying it, so the regression goes green on CI. That was already true in E2E. The comment says to run it locally on macOS before touching the rail's right edge. RailStaysOnTheVisiblePrompt no longer walks all 120 prompts of history. It asserts at five reading positions plus one jump that replaces the active range, keeping both original assertions — exactly one current tick, and it maps from the Turn being read — with a MutationObserver watching the count across every change rather than sampling at rest. Verified by mutation: offsetting the expected tick index by one fails it. Switching Sessions and rebuilding only the Host active range is not here. It needs shell state app-shell.tsx holds (#4582). Refs #4761. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 585 +++++++++++++++++++++ 1 file changed, 585 insertions(+) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index fdbae3708d..9c64c7a4bc 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2069,3 +2069,588 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { expect(tailScroller().scrollTop).toBeGreaterThanOrEqual(1); }, }; + +/** + * The prompt anchor rail (#563), rebuilt from `prompt-rail.spec.ts` (#4741). + * + * That spec exists because the rail failed three times in a row the same way: + * the code kept working and the pixels stopped. #2161 pinned it against + * `.maka-chat-shell` while Astryx's ChatLayout owned the scroll container, so + * the rail laid out across the whole conversation and scrolled off screen. + * #2338 parked it under macOS's overlay scrollbar, which takes no layout space + * but still swallows the pointer, so every tick rendered and none could be + * clicked. #2580 moved the tick onto Astryx's `Button`, whose label span put + * the bar back into normal flow — an inline box takes no width or height, so + * the bars computed to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10. + * + * None is visible to a static read of the CSS, and none is reachable from + * jsdom. All three need a real scroller with a real transcript, and none needs + * Electron. + * + * The E2E fixture reached these through a 120-prompt seeded session. Here the + * two props ChatView actually reads express it directly: the transcript holds + * only the Host's active range, and `transcriptTurnIndex` carries the rest of + * the landmarks. So the rail gets its full tick count without 120 Turns in the + * DOM — which is what the Host does in production too. + */ +const PROMPT_RAIL_TURN_COUNT = 120; + +/** `DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS`, restated to keep stories off preload. */ +const PROMPT_RAIL_ACTIVE_RANGE = 10; + +/** `MAX_PROMPT_RAIL_TICKS` in prompt-anchor-rail.tsx, which does not export it. */ +const PROMPT_RAIL_MAX_TICKS = 64; + +const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offset) => ({ + turnId: `turn-prompt-rail-${offset + 1}`, + sequence: offset + 1, + label: `第 ${offset + 1} 个问题`, +})); + +/** One Host active range, the way the Host hands it over: bounded and moving. */ +function promptRailMessagesFrom(firstIndex: number): StoredMessage[] { + return Array.from({ length: PROMPT_RAIL_ACTIVE_RANGE }, (_, offset) => { + const index = firstIndex + offset; + const turnId = `turn-prompt-rail-${index}`; + return [ + user(`msg-rail-${index}-u`, turnId, 500 - index * 2, `第 ${index} 个问题`), + assistant( + `msg-rail-${index}-a`, + turnId, + 499 - index * 2, + TAIL_LINES.slice(0, 4).join('\n\n'), + ), + ]; + }).flat(); +} + +const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_ACTIVE_RANGE + 1; + +const promptRailMessages = promptRailMessagesFrom(PROMPT_RAIL_TAIL_RANGE_START); + +function PromptRailHarness() { + return ( + + ); +} + +function railTicks(): HTMLElement[] { + return [...document.querySelectorAll('.maka-prompt-rail-tick')]; +} + +function railBars(): HTMLElement[] { + return [...document.querySelectorAll('.maka-prompt-rail-tick-bar')]; +} + +/** Positive on all four = the rail's box is inside the scrollport, clear of the dock. */ +function railInsets(): { + insetTop: number; + insetBottom: number; + insetRight: number; + dockClearance: number; +} { + const scroller = tailScroller(); + const rail = document.querySelector('.maka-prompt-rail'); + if (!rail) throw new Error('the prompt rail is missing'); + const scrollport = scroller.getBoundingClientRect(); + const box = rail.getBoundingClientRect(); + // Astryx renders the composer dock as the scroll container's last child; the + // rail measures it the same way, for want of a published hook. + const dock = scroller.lastElementChild?.getBoundingClientRect(); + if (!dock) throw new Error('the composer dock is missing'); + return { + insetTop: Math.round(box.top - scrollport.top), + insetBottom: Math.round(scrollport.bottom - box.bottom), + insetRight: Math.round(scrollport.right - box.right), + dockClearance: Math.round(dock.top - box.bottom), + }; +} + +export const PromptRailTicksPaintRealBoxes: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + + // Measured over ALL ticks, not a sample: a helper that skips what it + // cannot evaluate creates its blind spot exactly where a regression lives. + const bars = railBars().map((bar) => { + const box = bar.getBoundingClientRect(); + return { width: Math.round(box.width), height: Math.round(box.height) }; + }); + + expect(bars).toHaveLength(Math.min(PROMPT_RAIL_TURN_COUNT, PROMPT_RAIL_MAX_TICKS)); + // #2580 shipped bars at 0x0 — present in the DOM, painting nothing. + expect(Math.min(...bars.map((bar) => bar.width))).toBeGreaterThan(0); + expect(Math.min(...bars.map((bar) => bar.height))).toBeGreaterThan(0); + }, +}; + +export const PromptRailStaysInsideTheScrollport: Story = { + render: () => , + play: async () => { + const scroller = tailScroller(); + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + // Without an overflowing transcript the rail has nothing to be pinned + // against and the rest of this proves nothing. + expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight); + + for (const position of ['top', 'bottom'] as const) { + scroller.scrollTop = position === 'top' ? 0 : scroller.scrollHeight; + scroller.dispatchEvent(new Event('scroll')); + await painted(4); + + // The bottom is where it bites: a sticky offset is clamped by its + // containing block, and the chat shell ends a dock-height above the + // scrollport's bottom edge (#2161 showed up as a negative insetTop). + await waitFor(() => { + const insets = railInsets(); + expect( + { + insetTop: insets.insetTop >= 0, + insetBottom: insets.insetBottom >= 0, + insetRight: insets.insetRight >= 0, + dockClearance: insets.dockClearance >= 0, + }, + `rail geometry at the ${position}: ${JSON.stringify(insets)}`, + ).toEqual({ + insetTop: true, + insetBottom: true, + insetRight: true, + dockClearance: true, + }); + }); + } + }, +}; + +export const PromptRailHasNoGapsBetweenTicks: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(1)); + + // The hover falloff reads which tick the pointer entered. A gap between + // the hit boxes is a band where it is over the rail and over no tick, so + // the effect drops out and picks up again every few pixels of travel. + // Walked a pixel at a time rather than sampled between two ticks: a single + // midpoint would pass on a rail whose gaps sat anywhere else. + const bars = railBars(); + const first = bars[0].getBoundingClientRect(); + const last = bars[bars.length - 1].getBoundingClientRect(); + const x = Math.round(first.left + first.width / 2); + const misses: number[] = []; + for ( + let y = Math.round(first.top + first.height / 2); + y <= Math.round(last.top + last.height / 2); + y += 1 + ) { + if (!document.elementFromPoint(x, y)?.closest('.maka-prompt-rail-tick')) misses.push(y); + } + + expect(Math.round(last.bottom - first.top)).toBeGreaterThan(0); + expect(misses, `misses at y=${misses.slice(0, 12).join(',')}`).toHaveLength(0); + }, +}; + +export const PromptRailTickOwnsItsOwnHitBox: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); + + // `elementFromPoint`, not a dispatched pointer event: dispatched events + // cannot see occlusion, and macOS's overlay scrollbar occludes without + // taking layout space. + // + // Worth knowing before trusting a green run: this is load-bearing on macOS + // only. Linux's in-flow scrollbar moves the content column left instead of + // overlaying it, so the #2338 regression goes green on CI here exactly as + // it did in E2E. Run this story locally on macOS before merging anything + // that touches the rail's right edge. + const box = railTicks()[0].getBoundingClientRect(); + const found = document.elementFromPoint( + Math.round(box.left + box.width / 2), + Math.round(box.top + box.height / 2), + ); + + expect(found?.closest('.maka-prompt-rail')).not.toBe(null); + }, +}; + +/** Away from the tail, but still inside the band that would ask for history. */ +async function scrollAwayFromTail(): Promise { + const root = tailScroller(); + root.scrollTop = Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200); + root.dispatchEvent(new Event('scroll')); + await painted(4); +} + +async function scrollTranscriptTo(position: 'top' | 'bottom'): Promise { + const root = tailScroller(); + root.scrollTop = position === 'top' ? 0 : root.scrollHeight; + root.dispatchEvent(new Event('scroll')); + await painted(4); +} + +export const ActiveTurnsKeepStableDomIdentities: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + const sourceCount = Number( + messageList().getAttribute('data-turn-source-count'), + ); + expect(sourceCount).toBe(PROMPT_RAIL_ACTIVE_RANGE); + expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); + + // Marked on the elements themselves: a remount drops the attribute, which + // a count alone cannot tell apart from a remount that produced the same + // number of Turns. + for (const turn of document.querySelectorAll('[data-turn-id]')) { + turn.dataset.stableMountProbe = turn.dataset.turnId; + } + + await scrollTranscriptTo('bottom'); + await scrollAwayFromTail(); + + expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); + expect(document.querySelectorAll('[data-turn-id][data-stable-mount-probe]')).toHaveLength( + sourceCount, + ); + }, +}; + +export const ScrollingAwayPreservesTurnOwnedFocus: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + await scrollTranscriptTo('bottom'); + + const tailTurnId = `turn-prompt-rail-${PROMPT_RAIL_TURN_COUNT}`; + const turn = document.querySelector(`[data-turn-id="${tailTurnId}"]`); + if (!turn) throw new Error('the tail Turn is missing'); + + const action = document.createElement('button'); + action.dataset.turnOwnedAction = 'true'; + action.textContent = 'Turn-owned action'; + turn.append(action); + action.focus(); + const range = document.createRange(); + range.selectNodeContents(action); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + await scrollAwayFromTail(); + + await waitFor(() => { + const active = document.activeElement; + expect({ + retained: document.querySelector(`[data-turn-id="${tailTurnId}"]`) !== null, + focusRetained: active instanceof HTMLElement && active.dataset.turnOwnedAction === 'true', + selectionRetained: document.getSelection()?.isCollapsed === false, + }).toEqual({ retained: true, focusRetained: true, selectionRetained: true }); + }); + }, +}; + +export const OffscreenActiveTurnsStayFindable: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + const firstTurnId = document + .querySelector('[data-turn-id]') + ?.getAttribute('data-turn-id'); + const turnNumber = Number(firstTurnId?.split('-').at(-1)); + expect(turnNumber).toBeGreaterThan(0); + const needle = `第 ${turnNumber} 个问题`; + + await scrollTranscriptTo('bottom'); + + // `window.find` walks the rendered text, so a Turn skipped by + // `content-visibility` would not be there to find. The E2E original also + // asserted the AX tree; the storybook smoke audits the full AX tree of + // every story it runs, so that half is covered by running at all. + document.getSelection()?.removeAllRanges(); + // `window.find` is non-standard, so it is not on the DOM lib's Window. + const found = (window as unknown as { find(text: string): boolean }).find(needle); + + expect(found, `searching for ${needle}`).toBe(true); + expect(document.getSelection()?.toString() ?? '').toContain(needle); + document.getSelection()?.removeAllRanges(); + }, +}; + +/** Started by the play function, after its observer probe is in place. */ +let startPromptRailStream: (() => void) | undefined; + +const PROMPT_RAIL_STREAM_LINES = 40; + +/** + * The rail alongside a Turn that keeps growing. What is under test is that + * text updates inside one Turn do not rebuild the rail's IntersectionObserver + * — the E2E original reached this by sending a 40-line prompt through the fake + * backend, which is the same deltas ChatView sees, arriving by a longer road. + */ +function PromptRailStreamingHarness() { + const [lines, setLines] = useState(1); + useEffect(() => { + let frame = 0; + let running = false; + const tick = (): void => { + if (!running) return; + setLines((count) => (count >= PROMPT_RAIL_STREAM_LINES ? count : count + 1)); + frame = requestAnimationFrame(tick); + }; + startPromptRailStream = () => { + running = true; + frame = requestAnimationFrame(tick); + }; + return () => { + running = false; + cancelAnimationFrame(frame); + startPromptRailStream = undefined; + }; + }, []); + return ( + + ); +} + +export const StreamingDeltasKeepThePromptRailObserver: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); + + // Counted from here on, with the rail's observer already built: what is + // asserted is that the deltas after this point rebuild nothing. + const scroller = tailScroller(); + const NativeIntersectionObserver = window.IntersectionObserver; + let constructions = 0; + window.IntersectionObserver = class extends NativeIntersectionObserver { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + super(callback, options); + if (options?.root === scroller && options.rootMargin === '0px 0px -66% 0px') { + constructions += 1; + } + } + }; + + try { + startPromptRailStream?.(); + // Many same-Turn text updates, not one: a rebuild triggered by the first + // delta and by the fortieth are the same bug and only one of them shows + // up in a single-update check. + await waitFor( + () => { + expect(tailScroller().textContent).toContain( + TAIL_LINES[PROMPT_RAIL_STREAM_LINES - 1], + ); + }, + { timeout: 10_000 }, + ); + } finally { + window.IntersectionObserver = NativeIntersectionObserver; + } + + expect(constructions, 'the rail observer was rebuilt mid-stream').toBe(0); + expect(railBars().length).toBe(Math.min(PROMPT_RAIL_TURN_COUNT, PROMPT_RAIL_MAX_TICKS)); + }, +}; + +/** Where a Turn sits relative to the top of the scrollport. */ +function turnOffsetFromScroller(turnId: string): number { + const root = tailScroller(); + const turn = document.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + if (!turn) throw new Error(`turn ${turnId} is not mounted`); + return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top); +} + +/** + * The Host's half of a rail jump: a tick for a Turn outside the active range + * comes back out as `onLoadTranscriptTurn`, and the range moves to it. ChatView + * holds the claim until the Turn mounts, then aligns to it. + */ +function PromptRailNavigationHarness() { + const [firstIndex, setFirstIndex] = useState(PROMPT_RAIL_TAIL_RANGE_START); + return ( + setFirstIndex(target.sequence), + }} + /> + ); +} + +export const FirstRailClickLandsOnItsPromptAndHolds: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); + + // The case that used to fail: the head of the conversation is not mounted, + // so the jump has to bring it in, and the fill that follows changes + // scrollHeight underneath the tail-follow lock. A lock that ignores + // scroll-ups arriving with a changed height stays on and pulls the + // transcript back to the bottom — the click looks dead until the reader + // scrolls by hand. + const targetTurnId = 'turn-prompt-rail-1'; + expect(document.querySelector(`[data-turn-id="${targetTurnId}"]`)).toBe(null); + + railTicks()[0].click(); + + // Bounded on both sides: below is the Turn never arriving, above is it + // arriving and then being pulled off the top of the scrollport. + await waitFor( + () => expect(Math.abs(turnOffsetFromScroller(targetTurnId))).toBeLessThan(24), + { timeout: 10_000 }, + ); + expect(railTicks()[0].getAttribute('aria-current')).toBe('true'); + + // And stays: Turns keep resolving their content and remeasuring after the + // jump, so one that only wins the first frame reads as landing and then + // sliding away. + await painted(72); + const settled = turnOffsetFromScroller(targetTurnId); + expect(settled, `the prompt slid to ${settled} after landing`).toBeGreaterThan(-24); + expect(settled).toBeLessThan(24); + expect(railTicks()[0].getAttribute('aria-current')).toBe('true'); + }, +}; + +/** + * Which tick the reading position maps to, derived the way the rail derives + * it: the newest Turn when parked at the end, otherwise the first Turn in the + * top third of the scrollport, projected onto the tick count. + */ +function promptRailSnapshot(): { + currentIds: string[]; + expectedId: string | null; + sourceTurnId: string | null; +} { + const root = tailScroller(); + const ticks = railTicks(); + const currentIds = ticks + .filter((tick) => tick.getAttribute('aria-current') === 'true') + .map((tick) => tick.dataset.promptTurnId ?? ''); + const rootBounds = root.getBoundingClientRect(); + const atEnd = root.scrollHeight - root.scrollTop - root.clientHeight <= 2; + const turns = [...root.querySelectorAll('[data-transcript-turn-id]')] + .map((turn) => ({ + element: turn, + id: turn.dataset.transcriptTurnId ?? '', + index: Number(turn.dataset.transcriptTurnId?.split('-').at(-1)) - 1, + })) + .filter((turn) => turn.id.length > 0 && Number.isFinite(turn.index)); + const inScrollport = (element: HTMLElement, bottomEdge: number): boolean => { + const bounds = element.getBoundingClientRect(); + return bounds.bottom > rootBounds.top && bounds.top < bottomEdge; + }; + const readingBandTurns = turns + .filter(({ element }) => inScrollport(element, rootBounds.top + rootBounds.height * 0.34)) + .sort((left, right) => left.index - right.index); + const scrollportTurns = turns + .filter(({ element }) => inScrollport(element, rootBounds.bottom)) + .sort((left, right) => left.index - right.index); + const sourceTurn = atEnd + ? turns.reduce<(typeof turns)[number] | null>( + (latest, turn) => (latest === null || turn.index > latest.index ? turn : latest), + null, + ) + : (readingBandTurns[0] ?? scrollportTurns[0] ?? null); + const expectedRailIndex = + sourceTurn === null || ticks.length === 0 + ? null + : Math.round((sourceTurn.index * (ticks.length - 1)) / (PROMPT_RAIL_TURN_COUNT - 1)); + return { + currentIds, + expectedId: + expectedRailIndex === null ? null : (ticks[expectedRailIndex]?.dataset.promptTurnId ?? null), + sourceTurnId: sourceTurn?.id ?? null, + }; +} + +async function expectRailMatchesReadingPosition(where: string): Promise { + await waitFor(() => { + const snapshot = promptRailSnapshot(); + expect(snapshot.expectedId, `no visible Turn at the ${where}`).not.toBe(null); + expect(snapshot.currentIds, `rail at the ${where}: ${JSON.stringify(snapshot)}`).toEqual([ + snapshot.expectedId, + ]); + }); +} + +export const RailStaysOnTheVisiblePrompt: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); + + await scrollTranscriptTo('bottom'); + await expectRailMatchesReadingPosition('tail'); + expect(railTicks().at(-1)?.getAttribute('aria-current')).toBe('true'); + + // Counted across every change, not sampled at rest: two current ticks for + // one frame in the middle of a scroll is the failure, and it is invisible + // to a reading taken after the scroll settles. + const currentCounts: number[] = []; + const rail = document.querySelector('.maka-prompt-rail'); + if (!rail) throw new Error('the prompt rail is missing'); + const record = (): void => { + currentCounts.push(rail.querySelectorAll('.maka-prompt-rail-tick[aria-current="true"]').length); + }; + const observer = new MutationObserver(record); + observer.observe(rail, { attributes: true, subtree: true, attributeFilter: ['aria-current'] }); + record(); + + try { + // Reading positions across the active range, then a jump that replaces + // the range entirely — the two ways the rail's input changes. + for (const fraction of [0.75, 0.5, 0.25, 0]) { + root.scrollTop = Math.round((root.scrollHeight - root.clientHeight) * fraction); + root.dispatchEvent(new Event('scroll')); + await painted(4); + await expectRailMatchesReadingPosition(`${fraction * 100}% of the transcript`); + } + + railTicks()[0].click(); + await waitFor( + () => expect(document.querySelector('[data-turn-id="turn-prompt-rail-1"]')).not.toBe(null), + { timeout: 10_000 }, + ); + await scrollTranscriptTo('top'); + await expectRailMatchesReadingPosition('head'); + expect(railTicks()[0].getAttribute('aria-current')).toBe('true'); + } finally { + observer.disconnect(); + } + + expect(currentCounts.length).toBeGreaterThan(1); + expect( + currentCounts.every((count) => count === 1), + `current tick count over time: ${currentCounts.join(',')}`, + ).toBe(true); + }, +}; From 04738169cdea291fda6ca840deae13c9ade82da4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 19:24:54 +0800 Subject: [PATCH 4/5] test(desktop): cut the transcript stories to what they assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ablation over the three commits above: take out everything the stories can lose without losing coverage. 1225 lines to 1129, of which comments are 245 to 190. PromptRailTickOwnsItsOwnHitBox is gone because PromptRailHasNoGapsBetweenTicks already contains it. Both ask elementFromPoint what is under the rail. The hit-box story asked once, at the first tick's centre, and accepted any .maka-prompt-rail ancestor; the gaps story asks at every pixel from the first bar's centre to the last and requires a .maka-prompt-rail-tick. The tick lays its bar out with justify-content: flex-end, so the column the gaps story walks is the one nearest the overlay scrollbar — which is where #2338 hides. The macOS caveat moved onto the assertion that now carries it. The rail's fixture is gone as a fixture. promptRailMessagesFrom built the same Turns transcriptTurns already built, under a second turnId prefix; there is one generator now, and the prompt text is the label the rail indexes, which is what OffscreenActiveTurnsStayFindable searches for. What is left in the comments is what someone changing an assertion has to know: which mechanism it rides on, why a bound rather than an equality, and where a green run does not mean what it looks like. Why each regression happened is in the three commits above and in the issues they name, so it is not repeated at the assertion. Storybook smoke: 278 stories / 304 theme renders, all nineteen new stories among them. Refs #4761. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 230 ++++++--------------- 1 file changed, 67 insertions(+), 163 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 9c64c7a4bc..8e6de1655d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1432,22 +1432,16 @@ export const GoalDialogOpen: Story = { }; /** - * Where the transcript is looking while an answer streams into it. + * Transcript geometry. * - * This is the tier `transcript-scroll.spec.ts` was removed from in #4741: the - * assertions need a real layout engine, not Electron, and under a shared - * compositor they read a scroller mid-pin. Chromium settles per story here - * instead of per application launch. - * - * Positions are asserted against the scroller's own end, never as a pixel - * delta: a delta is satisfiable by two wrongs, where the content grew by as - * much as the view moved. + * Assert positions against the scroller's own end, never as a pixel delta: a + * delta is satisfiable by two wrongs, where the content grew by as much as the + * view moved. */ const TAIL_SCROLLER = '[data-chat-scroll-container="true"]'; -// Enough paragraphs to push the transcript past a viewport twice over, and -// few enough that the stream ends while the story is still settling — the -// final reading has to be taken against a transcript that stopped growing. +// Enough to push the transcript past a viewport twice, few enough that a +// stream of them ends while a story is still settling. const TAIL_LINES = Array.from( { length: 60 }, (_, index) => `第 ${index} 行:这一段用来把转录推过滚动视口的高度。`, @@ -1476,10 +1470,9 @@ function tailMetrics(): { } /** - * Samples every frame while the answer grows. The failure this guards against - * is the tail slipping away *while* content arrives, which a single reading - * afterwards cannot tell apart from a view dragged back at the last delta. - * Stops on the content rather than on a frame count. + * Samples every frame while the answer grows: a tail slipping away *while* + * content arrives is indistinguishable, afterwards, from a view dragged back + * at the last delta. Stops on the content; the budget is only a fuse. */ function measureTailLag(frameBudget: number): Promise<{ worstLag: number; @@ -1543,9 +1536,9 @@ function firstResidentTurnId(): string | null { } /** - * Whether the dock affordance is actually offered. It is always in the DOM — - * Astryx toggles opacity and pointer-events — so presence proves nothing and - * a visibility check passes on the transparent one. + * The dock affordance is always in the DOM — Astryx toggles opacity and + * pointer-events — so presence proves nothing and a visibility check passes on + * the transparent one. `dockOffered` is the real question. */ function dockButton(): HTMLButtonElement { const button = [...document.querySelectorAll('button')].find((candidate) => @@ -1563,24 +1556,20 @@ function dockOffered(): boolean { } /** - * A wheel the reader turned, delivered as an event rather than as input. - * - * The rule under test reads `composedPath()` and the overflow of what it - * crosses — DOM state, not compositor state — so a dispatched wheel exercises - * the same branch a real one does. What a dispatched wheel cannot do is scroll, - * which is why the cases below set `scrollTop` themselves where the reader's - * movement matters. Gestures that turn on Chromium's own scroll chaining stay - * in E2E. + * The rule this drives reads `composedPath()` and the overflow of what the + * wheel crossed — DOM state — so a dispatched wheel takes the same branch a + * real one does. What it cannot do is scroll, so cases that need the reader to + * move set `scrollTop` themselves. */ function wheelUp(target: Element): void { target.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); } /** A scroller inside the transcript, standing in for a tool-output box. */ -function injectNestedScroller(parent: Element, style: string): HTMLElement { +function injectNestedScroller(parent: Element): HTMLElement { const box = document.createElement('div'); box.dataset.nestedScroller = 'true'; - box.style.cssText = style; + box.style.cssText = 'height:120px;overflow-y:auto'; const filler = document.createElement('div'); filler.style.height = '2000px'; box.append(filler); @@ -1590,13 +1579,13 @@ function injectNestedScroller(parent: Element, style: string): HTMLElement { return box; } -/** Answered turns, oldest first. `index` may go negative as history loads. */ +/** Answered turns, oldest first. `from` may go negative as history loads. */ function transcriptTurns(from: number, count: number): StoredMessage[] { return Array.from({ length: count }, (_, offset) => { const index = from + offset; const turnId = `turn-scroll-${index}`; return [ - user(`msg-scroll-${index}-u`, turnId, 500 - index * 2, `第 ${index} 轮:把转录推长一点。`), + user(`msg-scroll-${index}-u`, turnId, 500 - index * 2, `第 ${index} 个问题`), assistant( `msg-scroll-${index}-a`, turnId, @@ -1610,17 +1599,12 @@ function transcriptTurns(from: number, count: number): StoredMessage[] { /** Stops the harness below, so the tail can be read against a settled transcript. */ let stopTailStream: (() => void) | undefined; -/** - * Streams one line per frame into a live Turn. The E2E original drove a fake - * backend echoing a 60-line prompt; what the assertion needs is growth past a - * viewport with the Turn still live, which the props express directly. - */ +/** Streams one line per frame into a live Turn. */ function StreamingTailHarness() { const [lines, setLines] = useState(1); useEffect(() => { - // Paced by frames, not by the clock. The sampler reads per frame too, so - // the two stay in step on a slow runner instead of the story taking - // proportionally longer than the machine it was sized on. + // Paced by frames, not by the clock, so it stays in step with the + // per-frame sampler on a slow runner. let frame = 0; let stopped = false; const tick = (): void => { @@ -1678,9 +1662,8 @@ function StreamingTailHarness() { export const StreamingTailFollow: Story = { render: () => , play: async () => { - // A fuse, not a duration: the sampler stops on the content, after a few - // dozen frames. Sized to run out well inside the smoke's per-story budget, - // so a stalled stream fails saying so instead of timing the story out. + // The fuse runs out inside the smoke's per-story budget, so a stalled + // stream fails saying so instead of timing the story out. const lag = await measureTailLag(600); // The samples have to have covered more than a viewport of real growth, or @@ -1689,9 +1672,7 @@ export const StreamingTailFollow: Story = { expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8); // Settle against a transcript that stopped growing, or the reading only - // says the sampler happened to catch a frame between deltas. Stopping it - // here rather than waiting the stream out keeps the story's length tied to - // the growth the assertion needed, which is already behind us. + // says the sampler caught a frame between deltas. stopTailStream?.(); await waitFor( () => { @@ -1703,11 +1684,7 @@ export const StreamingTailFollow: Story = { }, }; -/** - * Set by the harness below so a play function can drive the props React owns. - * One story renders per page, so a module-level handle addresses exactly one - * mounted harness. - */ +/** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; /** Every `onLoadEarlierHistory` the transcript asked for, anchor turn first. */ @@ -1715,10 +1692,9 @@ const historyLoads: string[] = []; const HISTORY_BATCH = 4; -// More batches than any story here consumes. A load chain stops on its own -// once a batch carries the reader past the band; running the history out -// instead retires the "earlier history" notice, and that removal is a height -// change above the reader with no arrival to explain it. +// More than any story here consumes. Running the history out retires the +// "earlier history" notice, and that removal is a height change above the +// reader with no arrival to explain it. const HISTORY_BATCHES_AVAILABLE = 8; /** A settled transcript with a turn the play function can make arrive. */ @@ -1733,11 +1709,7 @@ function SettledTranscriptHarness({ turns }: { turns: number }) { return ; } -/** - * The history-loading seam as props, which is all it ever was: the shell hands - * ChatView `hasOlderHistory` and a loader, and the loader prepends. The E2E - * original reached the same two props through a paginating fake backend. - */ +/** The history seam is two props: `hasOlderHistory`, and a loader that prepends. */ function HistoryHarness({ turns }: { turns: number }) { const [range, setRange] = useState({ from: 0, count: turns }); useEffect(() => { @@ -1844,7 +1816,7 @@ export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }); - const nested = injectNestedScroller(messageList(), 'height:120px;overflow-y:auto'); + const nested = injectNestedScroller(messageList()); await painted(6); historyLoads.length = 0; @@ -1946,23 +1918,12 @@ export const EarlierHistoryLandsAboveTheReader: Story = { /** * The reader going *up* through Turns that have never rendered. * - * New coverage rather than a restoration (#4761): the removed specs asserted - * turn-level anchoring against arrivals and tail-following lag while pinned, - * and both watch content coming to a reader who stays put. Neither watches a - * reader travelling through `content-visibility` placeholders as those - * materialise, because #4206 gives each Turn one boundary and made that stable - * by construction. #4259 moves the boundaries inside the Turn, where an upward - * traversal was measured moving `scrollHeight` by 63%. - * - * What is asserted is a bound, not stillness. A Turn off screen is laid out at + * A bound, not stillness. A Turn off screen is laid out at * `contain-intrinsic-block-size: auto 280px` and swaps to its real height on - * the way past; unless every Turn happens to be 280px tall, travelling through - * them moves things by construction, and measured here it moves the transcript - * about 8%. That is the cost #4206 already accepted. The bound is what #4206 - * buys on top of it — one estimate to correct per Turn, so the correction - * stays proportional to how many Turns were crossed rather than to how much is - * inside them. #4259 puts several boundaries in each Turn, and its measured - * 63% is what this fails on. + * the way past, so travelling through them moves things by construction — + * about 8% of the transcript here. What the bound says is that one Turn owes + * at most one estimate, keeping the correction proportional to Turns crossed + * rather than to what is inside them. */ const TRAVERSAL_STEP = 700; @@ -2071,27 +2032,14 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { }; /** - * The prompt anchor rail (#563), rebuilt from `prompt-rail.spec.ts` (#4741). - * - * That spec exists because the rail failed three times in a row the same way: - * the code kept working and the pixels stopped. #2161 pinned it against - * `.maka-chat-shell` while Astryx's ChatLayout owned the scroll container, so - * the rail laid out across the whole conversation and scrolled off screen. - * #2338 parked it under macOS's overlay scrollbar, which takes no layout space - * but still swallows the pointer, so every tick rendered and none could be - * clicked. #2580 moved the tick onto Astryx's `Button`, whose label span put - * the bar back into normal flow — an inline box takes no width or height, so - * the bars computed to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10. + * The prompt anchor rail (#563). All three of its shipped regressions had the + * same shape — the code kept working and the pixels stopped — so the + * assertions here are geometric. * - * None is visible to a static read of the CSS, and none is reachable from - * jsdom. All three need a real scroller with a real transcript, and none needs - * Electron. - * - * The E2E fixture reached these through a 120-prompt seeded session. Here the - * two props ChatView actually reads express it directly: the transcript holds - * only the Host's active range, and `transcriptTurnIndex` carries the rest of - * the landmarks. So the rail gets its full tick count without 120 Turns in the - * DOM — which is what the Host does in production too. + * Tick count comes from `transcriptTurnIndex`, not from mounted Turns: the + * transcript holds only the Host's active range and the index carries the rest + * of the landmarks, so the rail gets all 64 ticks against 10 Turns. That is + * what the Host does in production. */ const PROMPT_RAIL_TURN_COUNT = 120; @@ -2101,32 +2049,15 @@ const PROMPT_RAIL_ACTIVE_RANGE = 10; /** `MAX_PROMPT_RAIL_TICKS` in prompt-anchor-rail.tsx, which does not export it. */ const PROMPT_RAIL_MAX_TICKS = 64; +const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_ACTIVE_RANGE + 1; + const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offset) => ({ - turnId: `turn-prompt-rail-${offset + 1}`, + turnId: `turn-scroll-${offset + 1}`, sequence: offset + 1, label: `第 ${offset + 1} 个问题`, })); -/** One Host active range, the way the Host hands it over: bounded and moving. */ -function promptRailMessagesFrom(firstIndex: number): StoredMessage[] { - return Array.from({ length: PROMPT_RAIL_ACTIVE_RANGE }, (_, offset) => { - const index = firstIndex + offset; - const turnId = `turn-prompt-rail-${index}`; - return [ - user(`msg-rail-${index}-u`, turnId, 500 - index * 2, `第 ${index} 个问题`), - assistant( - `msg-rail-${index}-a`, - turnId, - 499 - index * 2, - TAIL_LINES.slice(0, 4).join('\n\n'), - ), - ]; - }).flat(); -} - -const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_ACTIVE_RANGE + 1; - -const promptRailMessages = promptRailMessagesFrom(PROMPT_RAIL_TAIL_RANGE_START); +const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_ACTIVE_RANGE); function PromptRailHarness() { return ( @@ -2207,19 +2138,9 @@ export const PromptRailStaysInsideTheScrollport: Story = { await waitFor(() => { const insets = railInsets(); expect( - { - insetTop: insets.insetTop >= 0, - insetBottom: insets.insetBottom >= 0, - insetRight: insets.insetRight >= 0, - dockClearance: insets.dockClearance >= 0, - }, + Object.entries(insets).filter(([, inset]) => inset < 0), `rail geometry at the ${position}: ${JSON.stringify(insets)}`, - ).toEqual({ - insetTop: true, - insetBottom: true, - insetRight: true, - dockClearance: true, - }); + ).toEqual([]); }); } }, @@ -2230,11 +2151,18 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { play: async () => { await waitFor(() => expect(railBars().length).toBeGreaterThan(1)); - // The hover falloff reads which tick the pointer entered. A gap between - // the hit boxes is a band where it is over the rail and over no tick, so - // the effect drops out and picks up again every few pixels of travel. - // Walked a pixel at a time rather than sampled between two ticks: a single - // midpoint would pass on a rail whose gaps sat anywhere else. + // Two things at once, both by walking the rail a pixel at a time: no gap + // between hit boxes, where the hover falloff would drop out and pick up + // again every few pixels; and nothing occluding the ticks, which is #2338 + // — macOS's overlay scrollbar takes no layout space but still swallows the + // pointer. `elementFromPoint`, not a dispatched pointer event, because a + // dispatched event cannot see occlusion at all. + // + // The occlusion half is load-bearing on macOS only: Linux's in-flow + // scrollbar moves the content column left instead of overlaying it, so + // #2338 goes green on CI here exactly as it did in E2E. Run this story + // locally on macOS before merging anything that touches the rail's right + // edge. const bars = railBars(); const first = bars[0].getBoundingClientRect(); const last = bars[bars.length - 1].getBoundingClientRect(); @@ -2253,30 +2181,6 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { }, }; -export const PromptRailTickOwnsItsOwnHitBox: Story = { - render: () => , - play: async () => { - await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); - - // `elementFromPoint`, not a dispatched pointer event: dispatched events - // cannot see occlusion, and macOS's overlay scrollbar occludes without - // taking layout space. - // - // Worth knowing before trusting a green run: this is load-bearing on macOS - // only. Linux's in-flow scrollbar moves the content column left instead of - // overlaying it, so the #2338 regression goes green on CI here exactly as - // it did in E2E. Run this story locally on macOS before merging anything - // that touches the rail's right edge. - const box = railTicks()[0].getBoundingClientRect(); - const found = document.elementFromPoint( - Math.round(box.left + box.width / 2), - Math.round(box.top + box.height / 2), - ); - - expect(found?.closest('.maka-prompt-rail')).not.toBe(null); - }, -}; - /** Away from the tail, but still inside the band that would ask for history. */ async function scrollAwayFromTail(): Promise { const root = tailScroller(); @@ -2325,7 +2229,7 @@ export const ScrollingAwayPreservesTurnOwnedFocus: Story = { await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); await scrollTranscriptTo('bottom'); - const tailTurnId = `turn-prompt-rail-${PROMPT_RAIL_TURN_COUNT}`; + const tailTurnId = `turn-scroll-${PROMPT_RAIL_TURN_COUNT}`; const turn = document.querySelector(`[data-turn-id="${tailTurnId}"]`); if (!turn) throw new Error('the tail Turn is missing'); @@ -2419,7 +2323,7 @@ function PromptRailStreamingHarness() { transcriptTurnIndex: promptRailIndex, runningStatus: true, liveTurn: { - turnId: `turn-prompt-rail-${PROMPT_RAIL_TURN_COUNT}`, + turnId: `turn-scroll-${PROMPT_RAIL_TURN_COUNT}`, phase: 'streamed', steps: [ { @@ -2497,7 +2401,7 @@ function PromptRailNavigationHarness() { return ( setFirstIndex(target.sequence), }} @@ -2516,7 +2420,7 @@ export const FirstRailClickLandsOnItsPromptAndHolds: Story = { // scroll-ups arriving with a changed height stays on and pulls the // transcript back to the bottom — the click looks dead until the reader // scrolls by hand. - const targetTurnId = 'turn-prompt-rail-1'; + const targetTurnId = 'turn-scroll-1'; expect(document.querySelector(`[data-turn-id="${targetTurnId}"]`)).toBe(null); railTicks()[0].click(); @@ -2637,7 +2541,7 @@ export const RailStaysOnTheVisiblePrompt: Story = { railTicks()[0].click(); await waitFor( - () => expect(document.querySelector('[data-turn-id="turn-prompt-rail-1"]')).not.toBe(null), + () => expect(document.querySelector('[data-turn-id="turn-scroll-1"]')).not.toBe(null), { timeout: 10_000 }, ); await scrollTranscriptTo('top'); From bfd79d3940930318959e411cc95a7f2eedad89d4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 20:24:11 +0800 Subject: [PATCH 5/5] test(desktop): close what adversarial review found in these stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review passes over the four commits above — one mutation-testing every assertion, one auditing the ported assertions against the deleted specs. Twelve stories were confirmed to go red when the defect they name is put back. What follows is what did not survive. ReaderScrolledUpIsNotPulledBack was genuinely flaky: 4 failures in ~310 runs at 4x CPU throttle, always "expected 16 to be less than or equal to 4". Its waitFor exited the instant the transcript grew, and growth crosses that threshold while the arriving Turn is still laid out at its content-visibility estimate — so the anchor was read outside any retry, on an intermediate layout. Both conditions retry together now. Retrying cannot launder a real failure: a reader who was pulled back sits at the tail, so the growth condition never holds again. Verified both ways — 0 red in 40 runs at 4x and 60 at 8x, and still red on the unconditional-writeToTail mutation. StreamingDeltasKeepThePromptRailObserver is gone. It installed its IntersectionObserver probe after the rail's observer already existed, so nothing proved the patched subclass was ever the one the rail built: changing the rootMargin literal it matches on leaves it green with the regression in place. It also had no geometry in it — it counted constructions and matched a string — so by this PR's own criterion it is a component test, not a story, and it was the slowest of the set (10.9s at 8x against a 15s budget). Removed rather than patched; #4761 carries it. Three assertions had been dropped without being named. StreamingTailFollow asserts again that the dock is not offered to a reader the tail never left. FirstRailClickLandsOnItsPromptAndHolds asserts again that motion is not collapsed — the bug it guards only exists while a scroll is in flight, and the fixture's own scrollBehavior is smooth, so the browser's reduced-motion state is the one thing left that can hollow it out. The third, OffscreenActiveTurnsStayFindable's accessibility-tree half, needs CDP and cannot come across; the comment claiming the smoke's AX audit covers it was wrong and now says so, and the PR body lists it as a gap. EarlierHistoryLandsAboveTheReader fixes its budget once the arrival has settled instead of recomputing it on every retry, where it would have grown along with the drift it bounds. PromptRailHasNoGapsBetweenTicks now requires its walk to have covered most of the rail, which the old last.bottom > first.top could not fail. SMOKE_HEADED=1 is the one change outside the stories. The #2338 comment told the reader to run the story locally on macOS; the smoke launches headless Chromium, which paints no platform scrollbar, so that instruction bought nothing. Measured while checking it: the overlay scrollbar's hit region is 1-14px from the scrollport edge, this story's walk is at 11px, and the story removed in the previous commit probed at 17px — outside it. Removing it was right; the reason given for it was not. Storybook smoke: 277 stories / 303 theme renders. Refs #4761. Generated-by: Claude Code --- apps/desktop/stories/app-shell.stories.tsx | 184 ++++++++------------- scripts/storybook-visual-smoke.mjs | 5 +- 2 files changed, 71 insertions(+), 118 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 8e6de1655d..3f61524c7e 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1681,6 +1681,9 @@ export const StreamingTailFollow: Story = { }, { timeout: 5_000 }, ); + + // A reader the tail never left has nothing to dock to. + expect(dockOffered()).toBe(false); }, }; @@ -1778,11 +1781,20 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { const anchorTop = turnTop(anchorTurnId); appendTurn?.(); - await waitFor(() => expect(tailMetrics().distance).toBeGreaterThan(before)); // The turn the reader was on is still where it was. Everything that // arrived, arrived below them. - expect(Math.abs(turnTop(anchorTurnId) - anchorTop)).toBeLessThanOrEqual(4); + // + // Both conditions retry together, because `distance` crosses `before` + // while the arriving Turn is still laid out at its `content-visibility` + // estimate — reading the anchor on that frame reads an intermediate + // layout, and on a slow renderer it is still 16px out. Retrying cannot + // launder a real failure: a reader who was pulled back is at the tail, so + // `distance` never gets above `before` again. + await waitFor(() => { + expect(tailMetrics().distance).toBeGreaterThan(before); + expect(Math.abs(turnTop(anchorTurnId) - anchorTop)).toBeLessThanOrEqual(4); + }); }, }; @@ -1904,14 +1916,23 @@ export const EarlierHistoryLandsAboveTheReader: Story = { // `content-visibility: auto`, so one that lands off screen is anchored // against its estimated height and settles a few pixels away from it; a // reader who went with the history instead moves by the whole insert. - await waitFor(() => { - const inserted = tailScroller().scrollHeight - heightBefore; - expect(inserted, JSON.stringify({ anchor, loads: historyLoads })).toBeGreaterThan(400); + await waitFor(() => expect( - Math.abs(turnTop(anchor.turnId) - anchor.top), - JSON.stringify({ anchor, inserted, now: turnTop(anchor.turnId), ...tailMetrics() }), - ).toBeLessThanOrEqual(Math.max(4, inserted * 0.02)); - }); + tailScroller().scrollHeight - heightBefore, + JSON.stringify({ anchor, loads: historyLoads }), + ).toBeGreaterThan(400), + ); + + // Fixed once, after the arrival has settled. Recomputed on every retry it + // would grow along with the drift it is supposed to bound, so a late + // `content-visibility` resolution could admit a reading that was failing. + await painted(8); + const inserted = tailScroller().scrollHeight - heightBefore; + const budget = Math.max(4, inserted * 0.02); + expect( + Math.abs(turnTop(anchor.turnId) - anchor.top), + JSON.stringify({ anchor, inserted, budget, now: turnTop(anchor.turnId), ...tailMetrics() }), + ).toBeLessThanOrEqual(budget); }, }; @@ -2158,11 +2179,17 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { // pointer. `elementFromPoint`, not a dispatched pointer event, because a // dispatched event cannot see occlusion at all. // - // The occlusion half is load-bearing on macOS only: Linux's in-flow - // scrollbar moves the content column left instead of overlaying it, so - // #2338 goes green on CI here exactly as it did in E2E. Run this story - // locally on macOS before merging anything that touches the rail's right - // edge. + // The occlusion half only bites in a headed browser on macOS: headless + // Chromium paints no platform scrollbar at all, and Linux's in-flow one + // moves the content column left instead of overlaying it. So #2338 is + // inert on CI, exactly as it was in E2E. Before touching the rail's right + // edge, run this on a Mac with `SMOKE_HEADED=1` — a plain local smoke run + // is headless and proves nothing about occlusion. + // + // Where the walk goes matters for the same reason. The bar sits at the + // tick's right edge, ~11px from the scrollport, inside the 14px the macOS + // overlay scrollbar claims; a column further left is outside it and sees + // no occlusion at all. const bars = railBars(); const first = bars[0].getBoundingClientRect(); const last = bars[bars.length - 1].getBoundingClientRect(); @@ -2176,7 +2203,15 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { if (!document.elementFromPoint(x, y)?.closest('.maka-prompt-rail-tick')) misses.push(y); } - expect(Math.round(last.bottom - first.top)).toBeGreaterThan(0); + // The walk has to have covered the rail, not two adjacent bars: a rail + // that laid out almost nothing would otherwise pass with no misses. + const walked = Math.round(last.bottom - first.top); + const railHeight = Math.round( + document.querySelector('.maka-prompt-rail')?.getBoundingClientRect().height ?? 0, + ); + expect(walked, `walked ${walked} of a rail ${railHeight} tall`).toBeGreaterThan( + railHeight * 0.8, + ); expect(misses, `misses at y=${misses.slice(0, 12).join(',')}`).toHaveLength(0); }, }; @@ -2271,9 +2306,12 @@ export const OffscreenActiveTurnsStayFindable: Story = { await scrollTranscriptTo('bottom'); // `window.find` walks the rendered text, so a Turn skipped by - // `content-visibility` would not be there to find. The E2E original also - // asserted the AX tree; the storybook smoke audits the full AX tree of - // every story it runs, so that half is covered by running at all. + // `content-visibility` would not be there to find. + // + // The E2E original also asserted the Turn's text was in the accessibility + // tree, which needs CDP and so did not come across. The smoke's AX audit + // is not a substitute: it checks for unnamed actionable nodes and + // duplicate landmarks, never that a given string is exposed. document.getSelection()?.removeAllRanges(); // `window.find` is non-standard, so it is not on the DOM lib's Window. const found = (window as unknown as { find(text: string): boolean }).find(needle); @@ -2284,105 +2322,6 @@ export const OffscreenActiveTurnsStayFindable: Story = { }, }; -/** Started by the play function, after its observer probe is in place. */ -let startPromptRailStream: (() => void) | undefined; - -const PROMPT_RAIL_STREAM_LINES = 40; - -/** - * The rail alongside a Turn that keeps growing. What is under test is that - * text updates inside one Turn do not rebuild the rail's IntersectionObserver - * — the E2E original reached this by sending a 40-line prompt through the fake - * backend, which is the same deltas ChatView sees, arriving by a longer road. - */ -function PromptRailStreamingHarness() { - const [lines, setLines] = useState(1); - useEffect(() => { - let frame = 0; - let running = false; - const tick = (): void => { - if (!running) return; - setLines((count) => (count >= PROMPT_RAIL_STREAM_LINES ? count : count + 1)); - frame = requestAnimationFrame(tick); - }; - startPromptRailStream = () => { - running = true; - frame = requestAnimationFrame(tick); - }; - return () => { - running = false; - cancelAnimationFrame(frame); - startPromptRailStream = undefined; - }; - }, []); - return ( - - ); -} - -export const StreamingDeltasKeepThePromptRailObserver: Story = { - render: () => , - play: async () => { - await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); - - // Counted from here on, with the rail's observer already built: what is - // asserted is that the deltas after this point rebuild nothing. - const scroller = tailScroller(); - const NativeIntersectionObserver = window.IntersectionObserver; - let constructions = 0; - window.IntersectionObserver = class extends NativeIntersectionObserver { - constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { - super(callback, options); - if (options?.root === scroller && options.rootMargin === '0px 0px -66% 0px') { - constructions += 1; - } - } - }; - - try { - startPromptRailStream?.(); - // Many same-Turn text updates, not one: a rebuild triggered by the first - // delta and by the fortieth are the same bug and only one of them shows - // up in a single-update check. - await waitFor( - () => { - expect(tailScroller().textContent).toContain( - TAIL_LINES[PROMPT_RAIL_STREAM_LINES - 1], - ); - }, - { timeout: 10_000 }, - ); - } finally { - window.IntersectionObserver = NativeIntersectionObserver; - } - - expect(constructions, 'the rail observer was rebuilt mid-stream').toBe(0); - expect(railBars().length).toBe(Math.min(PROMPT_RAIL_TURN_COUNT, PROMPT_RAIL_MAX_TICKS)); - }, -}; - /** Where a Turn sits relative to the top of the scrollport. */ function turnOffsetFromScroller(turnId: string): number { const root = tailScroller(); @@ -2414,6 +2353,17 @@ export const FirstRailClickLandsOnItsPromptAndHolds: Story = { play: async () => { await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); + // The bug only exists while a scroll is in flight: a jump that finishes in + // one frame has nothing for the tail-follow lock to collide with, which is + // why the E2E original ran under a fixture that asked for motion back. + // Here the fixture passes `scrollBehavior: 'smooth'` outright, so the one + // thing that can still collapse the scroll under it is the browser's own + // reduced-motion state. + expect( + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + 'a reduced-motion browser finishes the jump in one frame and this story stops testing anything', + ).toBe(false); + // The case that used to fail: the head of the conversation is not mounted, // so the jump has to bring it in, and the fill that follows changes // scrollHeight underneath the tail-follow lock. A lock that ignores diff --git a/scripts/storybook-visual-smoke.mjs b/scripts/storybook-visual-smoke.mjs index b7e3ec0bd2..6b4b697685 100644 --- a/scripts/storybook-visual-smoke.mjs +++ b/scripts/storybook-visual-smoke.mjs @@ -369,7 +369,10 @@ async function runCli() { ); } const { chromium } = await import('@playwright/test'); - const browser = await chromium.launch({ headless: true }); + // Headless Chromium paints no platform scrollbar, so anything a scrollbar + // can occlude is inert here and on CI. `SMOKE_HEADED=1` is how you check + // those by hand, on the platform whose scrollbar overlays the content. + const browser = await chromium.launch({ headless: process.env.SMOKE_HEADED !== '1' }); const server = await startStaticServer(staticDir); let problems; try {