Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
cd811e1
refactor(desktop): give the Renderer the transcript window
Astro-Han Sep 10, 2026
bba473c
test(desktop): measure the reader at transcript range boundaries
Astro-Han Sep 10, 2026
aedc843
fix(desktop): scope transcript window invalidation to what each read …
Astro-Han Sep 10, 2026
e8450a1
feat(desktop)!: drop the transcript range boundary notice
Astro-Han Sep 10, 2026
b61d08d
chore: drop the retired transcript history pending state from the hoo…
Astro-Han Sep 11, 2026
649b835
chore(desktop): refresh the renderer architecture ledger
Astro-Han Sep 11, 2026
327d96a
refactor(desktop): let the subscription's lifetime say when a transcr…
Astro-Han Sep 11, 2026
684c34c
fix(desktop): keep a navigation answerable across a trim of the windo…
Astro-Han Sep 11, 2026
5b8679e
fix(desktop): release the delivery budget a stranded page answer holds
Astro-Han Sep 11, 2026
c99ac2c
fix(desktop): give the transcript window one lifecycle and two invari…
Astro-Han Sep 11, 2026
693dc10
test(desktop): tell the displacement probe when the reader's hand is …
Astro-Han Sep 11, 2026
ec1682c
chore(desktop): keep hand-written e2e drivers out of the tree
Astro-Han Sep 11, 2026
15894f8
fix(desktop): drop the retired fill answer from the app-shell stories
Astro-Han Sep 11, 2026
32c1974
refactor(desktop)!: make the transcript window an anchored immutable …
Astro-Han Sep 11, 2026
770b271
chore(desktop): refresh the renderer architecture ledger
Astro-Han Sep 11, 2026
02fbd2e
fix(desktop): move the tail watermark with every catch-up page
Astro-Han Sep 11, 2026
84b1f05
fix(desktop): bring the app-shell stories onto the Renderer-owned window
Astro-Han Sep 11, 2026
2567918
test(ui): scroll the rail reading-position probe as a reader
Astro-Han Sep 11, 2026
16191d2
fix(ui): re-check the transcript band when the viewport resizes
Astro-Han Sep 11, 2026
bf3b56f
fix(desktop): mark the tail read only when the Renderer window reache…
Astro-Han Sep 11, 2026
5ca9127
fix(desktop): read the tail back before answering return-to-latest
Astro-Han Sep 11, 2026
368ac3d
fix(desktop): keep the reader's place across a Host epoch change
Astro-Han Sep 11, 2026
b239a9b
fix(desktop): give the WorkHub transcript the pixel band
Astro-Han Sep 11, 2026
ae1a52d
refactor(desktop): drop transcript window state nothing reads
Astro-Han Sep 11, 2026
e319d7c
refactor(desktop): publish WorkHub transcript snapshots from the stor…
Astro-Han Sep 11, 2026
1731276
refactor(desktop): let the read-marker gate stand alone
Astro-Han Sep 11, 2026
34853e5
refactor(desktop): name the transcript epoch-changed rejection once
Astro-Han Sep 11, 2026
16dfa83
refactor(ui): report reader scroll by phase only
Astro-Han Sep 11, 2026
93fc963
Merge remote-tracking branch 'origin/main' into refactor/transcript-r…
Astro-Han Sep 11, 2026
93e72a6
test(desktop): give the teardown fakes the transcript window contract
Astro-Han Sep 11, 2026
1c1c3ff
fix(desktop): release a transcript registration whose source is unava…
Astro-Han Sep 11, 2026
4bce9ec
refactor(desktop): drop the navigation wrapper and the surface's pass…
Astro-Han Sep 11, 2026
0a881c4
fix(desktop): acknowledge the transcript tail only for a visible reader
Astro-Han Sep 11, 2026
4ee5597
chore: trim comments and test scaffolding the transcript window PR added
Astro-Han Sep 11, 2026
0eb377c
Merge remote-tracking branch 'origin/main' into refactor/transcript-r…
Astro-Han Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { expect, test } from './fixtures';

const GAP = '.maka-transcript-gap-row';
const TURN = '.maka-transcript-turn';
/** Turns the partial-history fixture seeds. */
const PARTIAL_HISTORY_TURN_COUNT = 18;

test('bounded transcript ranges expose only their truthful boundary gaps', async ({
partialHistoryWindow: page,
Expand All @@ -44,14 +46,20 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async

const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]');
await expect(firstTurn).toBeVisible();
await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true');
// Where the jump landed, read from the reading position rather than from
// `data-search-highlight`: that highlight clears itself 2.2s after the
// command lands, so waiting for the Turn to mount and then asserting it
// fails whenever loading the page around it takes longer than the flash —
// measured here as a 3s pass turning into an 18s timeout under load.
await expect(oldestPrompt).toHaveAttribute('data-active', 'true');
await expect(olderGap).toHaveCount(0);
await expect(newerGap).toBeVisible();
await expect(newerGap.getByRole('button', {
name: /^(?:加载较新消息|Load newer messages)$/,
})).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(1);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);
// A jump lands on its own page, not on the whole history.
expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);

const loadNewer = newerGap.getByRole('button', {
name: /^(?:加载较新消息|Load newer messages)$/,
Expand All @@ -64,21 +72,26 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async

await loadNewer.click();
await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible();
await expect(olderGap).toBeVisible();
// Paging newer used to push the oldest Turn out of a Host-bounded range and
// put an older gap back. The Renderer owns the window now and keeps what the
// reader can still reach, so the only truthful boundary is still the newer one.
await expect(olderGap).toHaveCount(0);
await expect(newerGap).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(1);
await expect(loadNewer).toBeEnabled();
await expect(oldestPrompt).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(2);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);

const returnToLatest = page.getByRole('button', {
name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/,
});
await expect(returnToLatest).toBeVisible();
await returnToLatest.click();

await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible();
// Reading the tail page and rebuilding the window around it is slower than
// the paging above, and measured past the suite's 10s expect timeout here.
await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`))
.toBeVisible({ timeout: 30_000 });
await expect(newerGap).toHaveCount(0);
await expect(oldestPrompt).toBeVisible();
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);
expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);
});
239 changes: 229 additions & 10 deletions apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,34 @@

import type { CDPSession, Page } from '@playwright/test';
import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers';
import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract';
import { expect, test } from './fixtures';

const SCROLLER = '[data-chat-scroll-container="true"]';
const TURN = '.maka-transcript-turn';

/**
* The mounted range is now a band of pixels, not a Host constant: useChatScroll
* keeps the Turns within four screens of the reader and drops what sits beyond
* six, so what bounds this count is the viewport these tests set (700px) and
* how tall a fixture Turn is — no number the Main tail cache owns.
*
* Generous on purpose. The property worth guarding is that paging through 120
* Turns stops adding Turns; a range that kept everything it paged in would
* mount all 120, and a band that quietly doubled would pass no threshold that
* left this much room.
*/
const MOUNTED_TURNS_MAX = 40;

/**
* How far a page boundary is allowed to move the reader, in CSS pixels.
*
* Not a tolerance for "close enough" motion: scroll anchoring corrects in whole
* device pixels while these are read as fractional CSS pixels, so a correct
* frame lands within a pixel of zero and a frame that lost the reader lands a
* Turn away — hundreds.
*/
const DISPLACEMENT_MAX_PX = 2;

declare global {
interface Window {
__makaTranscriptCost?: {
Expand All @@ -51,9 +73,29 @@ declare global {
skipped: WeakSet<Element>;
skippedCount: number;
};
__makaTranscriptDisplacement?: {
boundaries: TranscriptBoundary[];
peakMounted: number;
stop(): void;
};
}
}

/**
* One frame where the mounted range changed: a page installed, or the band
* trimmed, or both.
*/
interface TranscriptBoundary {
readonly firstBefore: string;
readonly firstAfter: string;
readonly mountedBefore: number;
readonly mountedAfter: number;
/** Turns present in both frames, so a reader position can be compared. */
readonly carried: number;
readonly worstTurnId: string | null;
readonly worstPx: number;
}

/**
* Real wheel input at the centre of the scroller. Relative by construction: a
* wheel tick asks the compositor to move by a delta from wherever the scroller
Expand Down Expand Up @@ -125,6 +167,100 @@ async function observe(page: Page): Promise<void> {
});
}

/**
* Watch every frame for a change in the mounted range, and measure what that
* change did to the reader.
*
* A Turn the reader can still see is at `top` in the viewport and at
* `top + scrollTop` in the document. Between two frames with no input, its
* document position must not move, so `Δtop + ΔscrollTop` is zero — whatever
* the Renderer installed above it, the browser's scroll anchoring absorbed. A
* page that displaces the reader breaks that sum by however tall the rows it
* added or dropped were.
*
* Sampled per frame rather than per gesture: the frame that installs a page is
* the only one where the reader can be lost, and a per-gesture reading would
* subtract the reader's own scrolling back out and see nothing.
*/
async function observeDisplacement(page: Page): Promise<void> {
await page.evaluate((scrollerSelector) => {
const scroller = document.querySelector(scrollerSelector);
if (!scroller) throw new Error('the chat scroll container is missing');
const read = () => {
const tops = new Map<string, number>();
for (const turn of document.querySelectorAll<HTMLElement>('[data-turn-id]')) {
const turnId = turn.dataset.turnId;
if (turnId) tops.set(turnId, turn.getBoundingClientRect().top);
}
return { scrollTop: scroller.scrollTop, tops, key: [...tops.keys()].join(',') };
};
const state: { boundaries: unknown[]; peakMounted: number; stop(): void } = {
boundaries: [],
peakMounted: 0,
stop: () => { running = false; },
};
let running = true;
let previous = read();
// The last frame before the range started changing. Held across a run of
// changing frames so the measurement spans settled state to settled state:
// scroll anchoring corrects after layout, so a reading taken inside the
// change would report a correction that never reached the screen.
let settled: ReturnType<typeof read> | null = null;
let peakMounted = 0;
const tick = (): void => {
if (!running) return;
const current = read();
peakMounted = Math.max(peakMounted, current.tops.size);
state.peakMounted = peakMounted;
if (current.key !== previous.key) {
if (!settled) settled = previous;
} else if (settled) {
const before = settled;
settled = null;
const scrolled = current.scrollTop - before.scrollTop;
let carried = 0;
let worstPx = 0;
let worstTurnId: string | null = null;
for (const [turnId, top] of current.tops) {
const wasAt = before.tops.get(turnId);
if (wasAt === undefined) continue;
carried += 1;
const displaced = Math.abs(top - wasAt + scrolled);
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
if (displaced > worstPx) {
worstPx = displaced;
worstTurnId = turnId;
}
}
state.boundaries.push({
firstBefore: before.key.split(',')[0] ?? '',
firstAfter: current.key.split(',')[0] ?? '',
mountedBefore: before.tops.size,
mountedAfter: current.tops.size,
carried,
worstTurnId,
worstPx,
});
}
previous = current;
requestAnimationFrame(tick);
};
window.__makaTranscriptDisplacement = state as never;
requestAnimationFrame(tick);
}, SCROLLER);
}

async function displacement(page: Page): Promise<{
boundaries: readonly TranscriptBoundary[];
peakMounted: number;
}> {
return page.evaluate(() => {
const state = window.__makaTranscriptDisplacement;
if (!state) throw new Error('the transcript displacement probe is missing');
state.stop();
return { boundaries: state.boundaries, peakMounted: state.peakMounted };
});
}

interface CostSample {
transitionRuns: number;
animationStarts: number;
Expand All @@ -149,7 +285,32 @@ async function sample(page: Page): Promise<CostSample> {
});
}

/**
* A transcript opened at its tail keeps fetching older history until two
* screens of it sit above the reader, and trims what falls outside the band it
* retains, so the mounted rows churn for as long as that runs. Wait for the
* window to stop moving before touching a row: a locator resolved mid-churn
* points at an element the Renderer has already unmounted.
*/
async function settled(page: Page): Promise<void> {
const mounted = async (): Promise<string> => page.evaluate(() => {
const turns = document.querySelectorAll('[data-turn-id]');
return `${turns.length}:${turns[0]?.getAttribute('data-turn-id')}`;
});
let previous = await mounted();
await expect
.poll(async () => {
await page.waitForTimeout(250);
const current = await mounted();
const stable = current === previous;
previous = current;
return stable;
})
.toBe(true);
}

async function moveToTail(page: Page): Promise<void> {
await settled(page);
await page.locator(TURN).last().scrollIntoViewIfNeeded();
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
Expand Down Expand Up @@ -232,9 +393,10 @@ test('the browser skips the Turns the reader has scrolled past', async ({

/**
* The bound the Desktop transcript is built on: paging back through a history
* far longer than the active range mounts a bounded number of Turns, not a
* growing one. Sampled at every page rather than only at the end, because a
* range that overshoots and is trimmed afterwards is the regression.
* far longer than the retained band mounts a bounded number of Turns, not a
* growing one. Sampled at every page rather than only at the end, because the
* regression is a range that grows while the reader travels and is only trimmed
* once they stop.
*/
test('paging back through the whole history keeps the mounted range bounded', async ({
promptRailWindow: page,
Expand Down Expand Up @@ -267,14 +429,71 @@ test('paging back through the whole history keeps the mounted range bounded', as

expect(pages).toBeGreaterThan(0);
await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1');
expect(mountedMax).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS);
expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);

// Coming back from the far end is a range reload, not a scroll: the Host
// resolves a new window around the tail and the renderer mounts it. The
// suite's 10s expect timeout is sized for UI that is already on screen, and
// this step measured past it on a loaded CI runner.
// Coming back from the far end reads the tail page and rebuilds the window
// around it, so it is slower than the scrolling above. The suite's 10s expect
// timeout is sized for UI that is already on screen, and this step measured
// past it on a loaded CI runner.
await returnToLatest(page);
await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
.toHaveCount(1, { timeout: 30_000 });
expect(await turns.count()).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS);
expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
});

/**
* The scenario #5163 was reported from: quit Desktop, start it again, open a
* long Session, and scroll upward through history without stopping. The reader
* perceives stalls or jumps around range boundaries.
*
* The tests above establish that paging works and stays bounded. Neither says
* where the reader ended up while a page was installing, which is the whole of
* what that report is about. This one measures it: every frame the mounted
* range changes, whatever Turn the reader can still see must hold its document
* position.
*
* Displacement in pixels rather than frame timings on purpose — see this file's
* header for what happened to the timing assertions this suite replaced. A
* stall and a jump have the same cause here (a page boundary that moves
* content out from under the reader) and only one of them can be asserted
* without a clock.
*/
test('paging back never moves the reader at a range boundary', async ({
promptRailWindow: page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1_000, height: 700 });
await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`))
.toHaveCount(1);
const cdp = await page.context().newCDPSession(page);
const turns = page.locator('[data-turn-id]');
await moveToTail(page);
await observeDisplacement(page);

for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) {
const firstBefore = await turns.first().getAttribute('data-turn-id');
if (firstBefore === 'turn-prompt-rail-1') break;
await expect
.poll(async () => {
await wheel(page, cdp, { ticks: 12, deltaY: -120 });
return turns.first().getAttribute('data-turn-id');
})
.not.toBe(firstBefore);
}

const { boundaries, peakMounted } = await displacement(page);
// The probe has to have seen the thing it measures: a run that paged nothing,
// or one where every boundary replaced the range wholesale and carried no
// Turn across, proves nothing about the reader.
expect(boundaries.length).toBeGreaterThan(0);
expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0);

const displaced = boundaries.filter((boundary) => boundary.worstPx > DISPLACEMENT_MAX_PX);
expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`)
.toEqual([]);
// Sampled per frame, not per gesture: the bound above is read once the range
// has stopped moving, so a page that mounts the whole answer and trims it on
// a later frame passes it while costing the reader a full layout of every
// Turn it installed.
expect(peakMounted).toBeLessThanOrEqual(MOUNTED_TURNS_MAX);
});
Loading
Loading