From 8cfe8e5a05d13599d4debe401b67fda70680a3bc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 03:36:20 +0800 Subject: [PATCH 1/6] test(desktop): fence e2e sends on send readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Desktop e2e specs failed on CI with `element(s) not found` right after a send, and were green on rerun. Each pressed Enter after an intermediate signal — a mounted composer, a cleared draft, an updated model label — none of which says the shell will accept a submission. `awaitSendReady` waits for 发送 to be enabled instead — connections projected, no confirmed blocker, a draft to send, no send already in flight — and every send in these files goes through it. What the button cannot say is whether the readiness probe has answered: an unresolved snapshot is not a hard block, so Send stays enabled while the probe is in flight and `send()` awaits it again on its own. A slow probe is absorbed by the assertions below; a probe that comes back blocked still drops the submission with no feedback, and no test-side fence can close that. The assertions that observe the resulting Turn carry an explicit 20s, above the config-wide 10s default and below the 60s test timeout. In `accessibility-coverage.spec.ts` the fence goes after the Tab walk, not before it: a tooltip-carrying Astryx Button is disabled through `aria-disabled` and stays focusable, so `tabTo` reaches it either way and a fence placed earlier would only reopen the same window. Separately, `promptRailMotionWindow` gated readiness on `[data-turn-id]` alone. The transcript and the fixture attributes arrive on two unordered async paths — `runDeferredStartupRefreshes` fires `refreshSessions()` and `applyE2eFixture()` side by side, and only the second writes `data-maka-scroll-motion` once its `e2eFixture.getState()` IPC resolves — so a turn could paint before the document said anything about scroll motion, and the one-shot `page.evaluate` reading it saw `undefined`. One selector now requires both. Generated-by: Claude Code --- .../e2e/accessibility-coverage.spec.ts | 15 ++++++-- apps/desktop/e2e/fixtures.ts | 37 +++++++++++++++++-- apps/desktop/e2e/streaming-remount.spec.ts | 30 +++++++++++---- apps/desktop/e2e/transcript-scroll.spec.ts | 14 ++++++- 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index 3d7d3b6ab3..bb83c2197f 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -19,7 +19,7 @@ import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; import type { CDPSession, Locator, Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT } from './fixtures'; +import { awaitSendReady, expect, test, COMPOSER_INPUT } from './fixtures'; import { auditAxTree } from '../../../scripts/ax-tree-audit.mjs'; import { groupedNav } from '../src/renderer/settings/settings-nav'; @@ -183,6 +183,7 @@ test('data-backed conversation exposes ordered todos and keyboard access to tool await page.keyboard.insertText('/graph on'); const send = page.getByRole('button', { name: '发送' }); await tabTo(page, send, 'Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); await expect(page.getByText('Graph Mode 已开启', { exact: true })).toBeVisible(); await assertAxHealth(cdp, 'overlay/graph-mode-toast'); @@ -203,6 +204,7 @@ test('toast and error states expose healthy live regions', async ({ window: page const cdp = await page.context().newCDPSession(page); const composer = page.locator(COMPOSER_INPUT); await composer.fill('/graph history'); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.getByText('Graph 历史', { exact: true })).toBeVisible(); await assertAxHealth(cdp, 'overlay/graph-history-toast'); @@ -224,10 +226,17 @@ test('a streaming answer exposes a healthy live conversation state', async ({ wi await tabTo(page, composer, 'streaming composer', 60); await page.keyboard.insertText(FAKE_HOLD_OPEN_PROMPT); const send = page.getByRole('button', { name: '发送' }); + // After the Tab walk, not before it: a tooltip-carrying Astryx Button is + // disabled via `aria-disabled`, so it stays focusable and `tabTo` would + // reach it either way. await tabTo(page, send, 'streaming Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); - await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); + await expect(page.locator('.maka-bubble-streaming')).toContainText( + 'Fake backend waiting', + { timeout: 20_000 }, + ); await expect(page.getByRole('button', { name: '停止' })).toBeEnabled(); await assertAxHealth(cdp, 'conversation/streaming'); @@ -254,8 +263,8 @@ test('composer and workbar entry points expose named actionable controls', async await tabTo(page, composer, 'new-task composer', 60); await page.keyboard.insertText(prompt); const send = page.getByRole('button', { name: '发送' }); - await expect(send).toBeEnabled(); await tabTo(page, send, 'new-task Send button', 20); + await awaitSendReady(page); await page.keyboard.press('Enter'); await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible({ timeout: 30_000, diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 9eaacdcacf..61f7d1760c 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -71,6 +71,27 @@ export async function ensureSidebarExpanded(page: Page): Promise { ).toBeVisible(); } +/** + * Wait until the composer is in a state where Enter is a real submission: the + * connections projection has produced at least one connection, the draft is + * non-empty, no known blocker is showing and no earlier send is still in + * flight. 发送 is disabled for all of that, so it is the one signal covering + * it; intermediate signals (a cleared draft, an updated model label) resolve + * earlier and mean nothing here. + * + * It does NOT cover the submission-readiness probe: an unresolved snapshot is + * not a hard block, so the button is enabled while the probe is in flight, and + * `send()` awaits the probe again on its own. A slow probe is absorbed by the + * assertions' 20s; a probe that comes back blocked still drops the send with + * no feedback, which is a product gap, not something a test-side fence can + * close. + */ +export async function awaitSendReady(page: Page): Promise { + await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ + timeout: 20_000, + }); +} + /** * Wait for the default Host's Coordination Session and the WorkHub projection * to agree that the surface is ready. A mounted WorkHub main is not sufficient: @@ -670,9 +691,9 @@ export const test = base.extend({ use, ); }, - // This scenario is read-only at the Host boundary. Keep its real Electron + - // Host composition warm for the worker, while the test-scoped wrapper below - // restores Host and renderer state between tests. + // Keep this scenario's real Electron + Host composition warm for the worker, + // while the test-scoped wrapper below restores Host and renderer state + // between tests. Tests on it may run a Turn, so the reset is not read-only. promptRailWorker: [async ({}, use) => { await withE2eWindow({ seed: false, @@ -717,7 +738,15 @@ export const test = base.extend({ promptRailMotionWindow: async ({}, use) => { await withE2eWindow({ seed: false, - readinessSelector: '[data-turn-id]', + // The transcript and the fixture attributes arrive on two unordered + // async paths: `runDeferredStartupRefreshes` fires `refreshSessions()` + // and `applyE2eFixture()` side by side, and only the second one — after + // its `e2eFixture.getState()` IPC resolves — writes + // `data-maka-scroll-motion`. A turn can therefore paint while the + // document still says nothing about scroll motion. Requiring both in one + // selector is what makes "this window scrolls smoothly" true by the time + // a test body reads it. + readinessSelector: 'html[data-maka-scroll-motion="smooth"] [data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', showWindow: true, scrollMotion: 'smooth', diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index 998cd3c6ba..d0f238d988 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -23,7 +23,13 @@ import { FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import type { Locator } from '@playwright/test'; -import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; +import { + awaitSendReady, + COMPOSER_INPUT, + ensureSidebarExpanded, + expect, + test, +} from './fixtures'; interface SessionObservationLatchWindow extends Window { /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ @@ -54,10 +60,12 @@ test('a failed first observation seed reconnects to the live Turn', async ({ win const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-bubble-streaming')).toContainText( 'Fake backend waiting', + { timeout: 20_000 }, ); await page.getByRole('button', { name: '停止' }).click(); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { @@ -73,11 +81,12 @@ test('remounting a live surface leaves accumulated output settled', async ({ const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_REWRITE_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); const accumulatedOutput = 'prefix sk-123456789012345'; const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput); + await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); await ensureSidebarExpanded(page); @@ -137,8 +146,12 @@ test('keeps a completed reply after an interrupted turn and conversation remount expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); const composer = page.locator(COMPOSER_INPUT); await composer.fill('temporary conversation'); + await awaitSendReady(page); await composer.press('Enter'); - await expect(page.getByRole('log')).toContainText('Fake backend received: temporary conversation'); + await expect(page.getByRole('log')).toContainText( + 'Fake backend received: temporary conversation', + { timeout: 20_000 }, + ); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000, }); @@ -157,9 +170,11 @@ test('keeps a completed reply after an interrupted turn and conversation remount await expect(composer).toHaveText(''); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-bubble-streaming')).toContainText( 'Fake backend waiting', + { timeout: 20_000 }, ); const originalSessionId = await sidebar .locator('[data-session-id]:has([aria-current="page"])') @@ -178,9 +193,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount { timeout: 20_000 }, ).toBe(0); await composer.fill(FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT); - await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ - timeout: 20_000, - }); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.locator('.maka-user-message', { hasText: FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT, @@ -220,11 +233,12 @@ test('returning to a live conversation settles output accumulated while away', a expect(await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)).toBe(false); const composer = page.locator(COMPOSER_INPUT); await composer.fill(FAKE_HOLD_OPEN_PROMPT); + await awaitSendReady(page); await composer.press('Enter'); const accumulatedOutput = 'Fake backend waiting for the test to stop the Turn.'; const liveBubble = page.locator('.maka-bubble-streaming'); - await expect(liveBubble).toContainText(accumulatedOutput); + await expect(liveBubble).toContainText(accumulatedOutput, { timeout: 20_000 }); const sidebar = page.getByRole('navigation', { name: '任务列表' }); await page.getByRole('button', { name: '展开侧边栏' }).click(); @@ -239,9 +253,11 @@ test('returning to a live conversation settles output accumulated while away', a await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); await expect(composer).toHaveText(''); await composer.fill('temporary second conversation'); + await awaitSendReady(page); await composer.press('Enter'); await expect(page.getByRole('log')).toContainText( 'Fake backend received: temporary second conversation', + { timeout: 20_000 }, ); await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000, diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 8e599c683d..2c81b5a68d 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -17,7 +17,13 @@ * under the License. */ -import { expect, test, COMPOSER_INPUT, ensureSidebarExpanded } from './fixtures'; +import { + awaitSendReady, + expect, + test, + COMPOSER_INPUT, + ensureSidebarExpanded, +} from './fixtures'; import type { Page } from '@playwright/test'; /** @@ -196,6 +202,8 @@ function measureTailLag(page: Page, frames: number): Promise<{ async function sendPrompt(page: Page, text: string): Promise { const composer = page.locator(COMPOSER_INPUT); await composer.fill(text); + // Switching Session or model restarts asynchronous send admission. + await awaitSendReady(page); await composer.press('Enter'); } @@ -313,7 +321,9 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac .__makaBackgroundTailProbe = state; }, tailSessionId); await sendPrompt(page, LONG_PROMPT); - await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible(); + await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible({ + timeout: 20_000, + }); // The transcript collapses before each async replacement. This round trip // therefore exercises the production ordering that made a saved scrollTop From 000393144fab638ff26f073035405be786cf0255 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 03:36:20 +0800 Subject: [PATCH 2/6] ci: upload Desktop e2e test-results on failure The e2e config already sets `trace: 'retain-on-failure'` plus video and screenshot, but nothing collected the directory, so the evidence died with the runner: `gh api .../artifacts` is empty for every failing e2e run, which is why the mechanism behind a `transcript-scroll` failure could not be separated from the logs. Retention matches the diagnostic artifacts in `cli-package-validation.yml`. Generated-by: Claude Code --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4952b14c49..714b545eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,6 +368,18 @@ jobs: npm exec -w @maka/desktop -- playwright test \ --config e2e/playwright.config.ts --workers="$worker_count" + # Playwright keeps a trace, a video and a screenshot for every failed + # test. Without this they die with the runner, and an e2e flake can only + # be diagnosed by reproducing it. + - name: Upload Desktop e2e results + if: failure() && steps.plan.outputs.e2e == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-e2e-results + path: apps/desktop/e2e/test-results/ + if-no-files-found: ignore + retention-days: 7 + - name: Browser WebContentsView semantic smoke if: steps.plan.outputs.e2e == 'true' # Hosted Linux runners cannot configure Electron's SUID helper. This From 6398a25c854a326d236c9c4a4d7833e15b9ec5f2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 03:36:20 +0800 Subject: [PATCH 3/6] fix(ui): let a prompt rail click keep the edge it aimed at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a rail tick for a prompt outside the resident range asks the shell to load it, and the shell answers a load request by publishing a scroll target. That target is the search reveal: `block: 'center'` with the app's scroll motion. So two writers aim at the same turn with different answers — the rail's instant `block: 'start'` jump, and a smooth centring animation a frame or two behind it. Traced on `chat-prompt-rail`, whose head is 110 turns outside the resident range: the rail lands turn 1 at the top of the scrollport, the centring scroll then walks the transcript back down over ~950ms and clamps at `scrollTop` 0, leaving the prompt 68px below the top. The rail's hold re-aims and wins that race most of the time, which is what made this look like flake rather than a bug; when the range arrives while the animation is already running, the hold sees a moving scroller every frame, never re-aims, and the prompt never reaches the top at all. The reveal is not redundant — it is also what records the reading position, so dropping it for rail navigations loses the anchor a session switch restores from. Only its alignment conflicts, and the transcript is where both facts meet: it aimed the turn and it consumes the reveal, so it reconciles them itself and the shell keeps publishing one kind of target. A reveal for the turn the rail is holding is instant and top-aligned; every other one, a search result included, is centred and animated as before. Navigating anywhere else drops the claim, so a click the shell ignored cannot re-aim a later search. Generated-by: Claude Code --- packages/ui/src/chat-view.tsx | 26 +++++++++++++++++++++++++- packages/ui/src/use-chat-scroll.ts | 23 +++++++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index b090851041..824132e421 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -510,11 +510,35 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const scrollAuthority = useTranscriptScrollAuthority(); + // A rail click aims itself: it puts the prompt at the top of the scrollport + // and holds it there while the loaded range settles. Asking the shell to load + // an unloaded prompt also publishes a scroll target, and that reveal centres + // the turn with the app's scroll motion — a second answer to "where should + // this turn sit", and an animated one, which walks the prompt back off the + // top for a second after the rail has landed it. The reveal keeps its other + // job of recording the reading position; it just has to agree with the rail + // about the edge. + const railAimedTurnIdRef = useRef(undefined); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { + railAimedTurnIdRef.current = turn.turnId; loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } }, []); + // Navigating anywhere else resolves the claim, so a click the shell ignored + // cannot re-aim a later search. + if (props.scrollTargetTurn && props.scrollTargetTurn.turnId !== railAimedTurnIdRef.current) { + railAimedTurnIdRef.current = undefined; + } + const scrollTargetTurn = props.scrollTargetTurn + ? { + ...props.scrollTargetTurn, + align: + props.scrollTargetTurn.turnId === railAimedTurnIdRef.current + ? ('start' as const) + : ('center' as const), + } + : undefined; const inlineTransientMessages = tailTurnId ? transientMessages.filter((message) => { const turn = turns.find((candidate) => candidate.turnId === tailTurnId); @@ -540,7 +564,7 @@ export function ChatView(props: { scrollRef, sessionId: props.activeSession?.id, messages: props.messages, - target: props.scrollTargetTurn, + target: scrollTargetTurn, restoreTarget: props.restoreTargetTurn, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index d3e12ffb9b..5ab67edfb7 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -40,7 +40,13 @@ export function useChatScroll(input: { scrollRef: RefObject; sessionId?: string; messages: readonly StoredMessage[]; - target?: { turnId: string; nonce: number }; + /** + * A turn to reveal, and where its requester wants it. `center` with the + * app's scroll motion is the reveal a search result wants; `start` is for a + * requester that is already aiming this turn itself and only needs the + * reveal to agree with it, instantly and at the same edge. + */ + target?: { turnId: string; nonce: number; align?: 'start' | 'center' }; restoreTarget?: { turnId: string; unavailable?: boolean }; onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; @@ -182,7 +188,12 @@ export function useChatScroll(input: { useEffect(() => { const explicitTarget = input.target?.turnId - ? { kind: 'search' as const, turnId: input.target.turnId, nonce: input.target.nonce } + ? { + kind: 'search' as const, + turnId: input.target.turnId, + nonce: input.target.nonce, + align: input.target.align ?? ('center' as const), + } : undefined; const restoreTurnId = activation.current?.restoreTurnId; const target = explicitTarget ?? (restoreTurnId @@ -217,9 +228,13 @@ export function useChatScroll(input: { } handledTarget.current = chosen; const targetElement = element as HTMLElement; + const alignToStart = target.kind !== 'search' || target.align === 'start'; targetElement.scrollIntoView({ - behavior: target.kind === 'search' ? input.behavior : 'auto', - block: target.kind === 'search' ? 'center' : 'start', + // A reveal that agrees with a requester already aiming this turn has to + // be instant too: an animated one is a second writer moving the + // scroller for a second after the requester has landed it. + behavior: alignToStart ? 'auto' : input.behavior, + block: alignToStart ? 'start' : 'center', }); // A command can land at the browser's existing offset and therefore // produce no scroll event. Reuse the authority-backed reporter so that From 95af04cd611c8d18d9cb26ff965fa1319fe83912 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 03:36:20 +0800 Subject: [PATCH 4/6] test(desktop): retake the tab walk when a cold start moves its start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enterMainFromSkipLink` parks focus on `body` and asserts the skip link is within ten Tab presses of the document start. Parking is not a one-shot the renderer respects: the composer restores its draft caret with `getSelection().addRange(...)`, and a range set inside a `contenteditable` focuses it — so once per cold start, tens of milliseconds after the park and with no `focus()` call anywhere to fence on, focus lands in the composer. A walk that starts there has to run out the rest of the tab ring and wrap around, which is nine of the ten presses on a quiet surface and over budget as soon as the footer carries one more control. The restore fires once, so the park and the walk retry together rather than the budget growing to absorb the wrap-around. The budget is the assertion — it says the skip link is a few presses from the document start — and it stays where it was. Generated-by: Claude Code --- .../e2e/accessibility-coverage.spec.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/desktop/e2e/accessibility-coverage.spec.ts b/apps/desktop/e2e/accessibility-coverage.spec.ts index bb83c2197f..41ed8e69ac 100644 --- a/apps/desktop/e2e/accessibility-coverage.spec.ts +++ b/apps/desktop/e2e/accessibility-coverage.spec.ts @@ -52,13 +52,27 @@ async function tabTo(page: Page, target: Locator, label: string, limit = 30): Pr ).toBe(true); } +/** + * Walk to the skip link from the document start, taking the start back if a + * cold start moves it. + * + * Parking focus on `body` is not a one-shot the renderer respects: the composer + * restores its draft caret with `getSelection().addRange(...)`, and a range set + * inside a `contenteditable` focuses it — so once per cold start, tens of + * milliseconds after the park and with no `focus()` call to fence on, focus + * lands in the composer. A walk that starts there has to run out the tab ring + * and wrap around, which is over budget. The restore fires once, so re-park and + * walk again rather than widening the budget — the budget is the assertion. + */ async function enterMainFromSkipLink(page: Page): Promise { - await page.evaluate(() => { - document.body.tabIndex = -1; - document.body.focus(); - }); const skipLink = page.getByRole('link', { name: '跳到主要内容' }); - await tabTo(page, skipLink, 'skip link', 10); + await expect(async () => { + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + }); + await tabTo(page, skipLink, 'skip link', 10); + }).toPass({ timeout: 30_000 }); await page.keyboard.press('Enter'); await expect(page.getByRole('main')).toBeFocused(); await page.evaluate(() => document.body.removeAttribute('tabindex')); From f0de24ee9b44234ea4b59c21a7f3f266915edd51 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 03:36:20 +0800 Subject: [PATCH 5/6] test(desktop): name the prompt rail fixture window's locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other fixture window passes one; this one did not, so its renderer took the host's. Any test that reaches a control by its label — the send fence in `transcript-scroll.spec.ts:259` is the one that caught it — therefore passed on a Chinese desktop and failed on the English CI runner with `element(s) not found`. Generated-by: Claude Code --- apps/desktop/e2e/fixtures.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 61f7d1760c..cd0f6cf331 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -703,6 +703,10 @@ export const test = base.extend({ // assertion that names it. readinessSelector: '[data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', + // Every other fixture window names its locale; without one the renderer + // takes the host's, so any test that reaches a control by its label + // passes on a Chinese desktop and cannot find it on an English CI runner. + locale: 'zh', showWindow: true, }, async (page, { app }) => { const viewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight })); From 84e3a854f24f6e2ec354f51f4119e64fffbfc3ee Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 10:03:33 +0800 Subject: [PATCH 6/6] fix(ui): spend a prompt-rail alignment claim on the navigation that owns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rail click on an unloaded prompt aims the turn at the top of the scrollport and leaves a claim so the shell's reveal for that turn agrees with it instead of centring it a frame later. The claim named a turn and nothing consumed it, so it outlived the navigation it belonged to: the shell issues a fresh nonce for every navigation but the same turnId, so a later global search for that same turn was still read as the rail's, and lost its own contract — smooth and centred — to an instant top jump. Bind the claim to the first target that arrives for its turn, and spend it on anything else. A later search for the same turn is a different command with its own nonce and gets the search contract back. The rule is now a pure function so the rail-then-search sequence has a unit test rather than another Electron round trip. Two smaller corrections in the e2e fixtures: `promptRailMotionWindow` was the remaining window inheriting the host locale, which is the failure that made this branch's CI red once already; and `awaitSendReady`'s comment claimed its 20s assertions absorbed a slow readiness probe, which they do not — the probe's own barrier gives up at 30s, and the 60s test budget, not that barrier, is what bounds the window a send can take. Generated-by: Claude Code --- apps/desktop/e2e/fixtures.ts | 13 +++-- .../__tests__/rail-alignment-claim.test.ts | 56 +++++++++++++++++++ packages/ui/src/chat-view.tsx | 50 +++++++++++------ 3 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/__tests__/rail-alignment-claim.test.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index cd0f6cf331..57f2e203ba 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -81,10 +81,14 @@ export async function ensureSidebarExpanded(page: Page): Promise { * * It does NOT cover the submission-readiness probe: an unresolved snapshot is * not a hard block, so the button is enabled while the probe is in flight, and - * `send()` awaits the probe again on its own. A slow probe is absorbed by the - * assertions' 20s; a probe that comes back blocked still drops the send with - * no feedback, which is a product gap, not something a test-side fence can - * close. + * `send()` awaits the probe again on its own — a first send inside a barrier + * that gives up at 30s. The post-send assertions wait 20s, which covers every + * admission measured here but not that whole barrier. Widening them past it + * buys nothing: the 60s test budget is the real cap, a send admitted at 25s + * leaves the multi-send specs unable to finish anyway, and the only change + * would be trading a named assertion failure for a bare test timeout. A probe + * that comes back blocked still drops the send with no feedback, which is a + * product gap, not something a test-side fence can close. */ export async function awaitSendReady(page: Page): Promise { await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ @@ -752,6 +756,7 @@ export const test = base.extend({ // a test body reads it. readinessSelector: 'html[data-maka-scroll-motion="smooth"] [data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', + locale: 'zh', showWindow: true, scrollMotion: 'smooth', }, use); diff --git a/packages/ui/src/__tests__/rail-alignment-claim.test.ts b/packages/ui/src/__tests__/rail-alignment-claim.test.ts new file mode 100644 index 0000000000..98de36ac69 --- /dev/null +++ b/packages/ui/src/__tests__/rail-alignment-claim.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveRailAlignedTarget } from '../chat-view.js'; + +test('a rail claim aims its own navigation and nothing after it', () => { + // The click, before the shell has published anything. + let claim = resolveRailAlignedTarget({ turnId: 'a' }, undefined).claim; + assert.deepEqual(claim, { turnId: 'a' }); + + // The load the click asked for. The reveal has to agree with the rail. + let resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // Still the same command, re-rendered while the loaded range settles. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'start'); + claim = resolved.claim; + + // A later search for the same Turn is a different command, and wants the + // search contract back. + resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 2 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search for another Turn spends an unconsumed rail claim', () => { + const resolved = resolveRailAlignedTarget({ turnId: 'a' }, { turnId: 'b', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.equal(resolved.claim, undefined); +}); + +test('a search with no rail claim behind it is centred', () => { + const resolved = resolveRailAlignedTarget(undefined, { turnId: 'a', nonce: 1 }); + assert.equal(resolved.target?.align, 'center'); + assert.deepEqual(resolved.target, { turnId: 'a', nonce: 1, align: 'center' }); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 824132e421..ea812fe1be 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -91,6 +91,35 @@ export interface ChatViewGoalIndicatorProps { goalIndicator?: SessionContextGoal; } +/** A rail click's outstanding request that the reveal for its Turn agree with it. */ +export type RailAlignmentClaim = { turnId: string; nonce?: number }; + +/** + * Which edge the transcript's reveal should use for the target the shell is + * publishing, and what is left of the rail's claim afterwards. + * + * A claim belongs to the one navigation its click asked for, not to the Turn: + * it binds to the first target that arrives for that Turn and is spent on + * anything else. A later search for the same Turn is a different command with + * its own nonce, and gets the search contract back. + */ +export function resolveRailAlignedTarget( + claim: RailAlignmentClaim | undefined, + target: T | undefined, +): { + claim: RailAlignmentClaim | undefined; + target: (T & { align: 'start' | 'center' }) | undefined; +} { + if (!target) return { claim, target: undefined }; + const aimedByRail = claim !== undefined + && claim.turnId === target.turnId + && (claim.nonce === undefined || claim.nonce === target.nonce); + return { + claim: aimedByRail ? { turnId: target.turnId, nonce: target.nonce } : undefined, + target: { ...target, align: aimedByRail ? 'start' : 'center' }, + }; +} + /** Persistent navigation position with a direct path back to the transcript tail. */ export function TranscriptHistoryNotice({ title, @@ -518,27 +547,16 @@ export function ChatView(props: { // top for a second after the rail has landed it. The reveal keeps its other // job of recording the reading position; it just has to agree with the rail // about the edge. - const railAimedTurnIdRef = useRef(undefined); + const railClaimRef = useRef(undefined); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { - railAimedTurnIdRef.current = turn.turnId; + railClaimRef.current = { turnId: turn.turnId }; loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } }, []); - // Navigating anywhere else resolves the claim, so a click the shell ignored - // cannot re-aim a later search. - if (props.scrollTargetTurn && props.scrollTargetTurn.turnId !== railAimedTurnIdRef.current) { - railAimedTurnIdRef.current = undefined; - } - const scrollTargetTurn = props.scrollTargetTurn - ? { - ...props.scrollTargetTurn, - align: - props.scrollTargetTurn.turnId === railAimedTurnIdRef.current - ? ('start' as const) - : ('center' as const), - } - : undefined; + const railAlignment = resolveRailAlignedTarget(railClaimRef.current, props.scrollTargetTurn); + railClaimRef.current = railAlignment.claim; + const scrollTargetTurn = railAlignment.target; const inlineTransientMessages = tailTurnId ? transientMessages.filter((message) => { const turn = turns.find((candidate) => candidate.turnId === tailTurnId);