diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 42ce777aca..1a9e24f6bc 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -483,7 +483,9 @@ async function withE2eWindow( }); let page: Page; try { - page = await app.firstWindow(); + // Parallel CI workers and cold Windows hosts can finish process launch + // before the first BrowserWindow crosses Playwright's 30s default. + page = await app.firstWindow({ timeout: 60_000 }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); const logs = mainLogs.length > 0 ? `\nElectron main console:\n${mainLogs.join('\n')}` : ''; @@ -514,7 +516,12 @@ async function withE2eWindow( try { if (app) await closeElectronApplication(app, 5_000); } finally { - await rm(userDataDir, { recursive: true, force: true }); + await rm(userDataDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } } } @@ -573,6 +580,7 @@ type E2eTestFixtures = { promptRailWindow: Page; threadSearchWindow: Page; partialHistoryWindow: Page; + oversizedTurnWindow: Page; requestHeaderRowWindow: Page; permissionCenterWindow: Page; newTaskTargetWindow: Page; @@ -777,6 +785,17 @@ export const test = base.extend({ showWindow: true, }, use); }, + // One Turn larger than the transcript byte budget. Shown because the test + // reads Chromium's actual content-visibility state while crossing it. + oversizedTurnWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + readinessSelector: '[data-turn-id="turn-oversized-fixture"]', + e2eFixtureScenario: 'chat-oversized-turn', + locale: 'zh', + showWindow: true, + }, use); + }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — // the connection type whose detail page owns the custom request headers // editor. Shown, because what this window is for is a rendered box diff --git a/apps/desktop/e2e/oversized-turn-render.spec.ts b/apps/desktop/e2e/oversized-turn-render.spec.ts new file mode 100644 index 0000000000..76a8e9a781 --- /dev/null +++ b/apps/desktop/e2e/oversized-turn-render.spec.ts @@ -0,0 +1,52 @@ +/* + * 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 { expect, test } from './fixtures'; + +const SEGMENT = '[data-maka-transcript-boundary]'; + +test('an oversized single Turn skips offscreen timeline blocks', async ({ + oversizedTurnWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + const segments = page.locator(SEGMENT); + await expect(segments).not.toHaveCount(0); + expect(await segments.count()).toBeGreaterThan(80); + + const state = await segments.evaluateAll((elements) => { + const rows = elements as HTMLElement[]; + return { + automatic: rows.filter((element) => + getComputedStyle(element).contentVisibility === 'auto').length, + skipped: rows.filter((element) => + !element.checkVisibility({ contentVisibilityAuto: true })).length, + }; + }); + expect(state.automatic).toBe(await segments.count()); + expect(state.skipped).toBeGreaterThan(0); + + const first = segments.first(); + await first.evaluate((element) => element.scrollIntoView({ block: 'center' })); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + expect(await first.evaluate((element) => + element.checkVisibility({ contentVisibilityAuto: true }), + )).toBe(true); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 6c741c5248..74c6ab260e 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -40,6 +40,7 @@ import { LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, + OVERSIZED_TURN_SESSION_ID, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, AGENT_GRAPH_SESSION_ID, @@ -49,6 +50,8 @@ import { import { partialHistoryMessages, partialHistorySession, + oversizedTurnMessages, + oversizedTurnSession, promptRailMessages, promptRailSession, turnMessages, @@ -71,6 +74,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative-browser', 'chat-prompt-rail', 'chat-partial-history', + 'chat-oversized-turn', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -191,6 +195,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'chat-partial-history': return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; + case 'chat-oversized-turn': + return { ...state, activeSessionId: OVERSIZED_TURN_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -276,6 +282,13 @@ export async function seedE2eFixture(input: { partialHistoryMessages(now), ); } + if (scenario === 'chat-oversized-turn') { + await writeSession( + input.workspaceRoot, + oversizedTurnSession(now), + oversizedTurnMessages(now), + ); + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index fb26506f70..068711ecaf 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -21,6 +21,7 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, AGENT_GRAPH_SESSION_ID, + OVERSIZED_TURN_SESSION_ID, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, @@ -201,3 +202,101 @@ export function partialHistoryMessages(now: number): StoredMessage[] { } return messages; } + +export function oversizedTurnSession(now: number): SessionHeader { + return header({ + id: OVERSIZED_TURN_SESSION_ID, + name: '单轮超长渲染边界示例', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 60_000, + }); +} + +/** + * One synthetic Turn that exceeds the Desktop transcript byte budget by + * itself. Alternating answers and tool evidence create many stable visual + * blocks inside the same Turn, reproducing the shape that whole-Turn + * containment cannot bound without carrying any real conversation data. + */ +export function oversizedTurnMessages(now: number): StoredMessage[] { + const turnId = 'turn-oversized-fixture'; + const messages: StoredMessage[] = [{ + type: 'user', + id: 'msg-oversized-user', + turnId, + ts: now - 10 * 60_000, + text: '检查一组独立的合成步骤,并逐项给出简短结果。', + }]; + const prose = [ + '这一段只包含确定性的合成文本,用于测量长对话的滚动渲染。', + '', + '- 已检查输入边界', + '- 已记录合成结果', + '- 下一步继续验证', + ].join('\n'); + const toolOutput = 'synthetic output line\n'.repeat(600); + // Reasoning is the block #4256 reports as the dominant cost, and it is unlike + // collapsed tool output: `ChatReasoning` keeps its body mounted and only swaps + // a wrapper class, so a folded reasoning run still lays out. Give each step a + // multi-paragraph run so the `.maka-deep-thinking` boundary carries real, + // mounted content rather than free collapsed-stdout bytes. + const reasoning = [ + '先确认这一步的输入边界:空样例、超长样例、并发样例三类分别对照期望结果,', + '逐项记录偏差,再对合成输出做一次去抖动检查,确保占位高度不随展开态漂移。', + '', + '- 输入域:空 / 超长 / 并发', + '- 期望:确定性、可重放', + '- 检查:占位高度稳定,无跨帧跳变', + '', + '综合以上,本步没有回归,可以进入下一组合成检查。', + ].join('\n'); + for (let index = 1; index <= 48; index += 1) { + const ts = now - (49 - index) * 10_000; + messages.push({ + type: 'assistant', + id: `msg-oversized-assistant-${index}`, + turnId, + ts, + text: `### 合成步骤 ${index}\n\n${prose.repeat(12)}`, + thinking: { text: `${reasoning}\n\n${reasoning}\n\n${reasoning}` }, + modelId: 'glm-5.1', + }); + messages.push({ + type: 'tool_call', + id: `tool-oversized-${index}`, + turnId, + ts: ts + 1_000, + toolName: 'Bash', + displayName: `合成检查 ${index}`, + intent: `读取第 ${index} 组固定测试数据`, + args: { cmd: `fixture-check --step ${index}`, cwd: '/workspace/maka' }, + }); + messages.push({ + type: 'tool_result', + id: `tool-oversized-result-${index}`, + turnId, + ts: ts + 2_000, + toolUseId: `tool-oversized-${index}`, + isError: false, + durationMs: 100 + index, + content: { + kind: 'terminal', + cwd: '/workspace/maka', + cmd: `fixture-check --step ${index}`, + status: 'completed', + exitCode: 0, + output: { + mode: 'pipes', + stdout: toolOutput, + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + }); + } + return messages; +} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 02255173ea..128217cf21 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -36,6 +36,7 @@ export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const AGENT_GRAPH_SESSION_ID = 'e2e-fixture-agent-graph'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; +export const OVERSIZED_TURN_SESSION_ID = 'e2e-fixture-oversized-turn'; /** Exceeds both the 64-tick rail and the bounded active transcript range. */ export const PROMPT_RAIL_PROMPT_COUNT = 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index ebe039fd44..c8c2074922 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -123,6 +123,44 @@ gap: var(--space-1); } +/* A transcript range is bounded by complete Turns, so one unusually large + Turn can still be much taller than the scrollport. The outer Turn's + content-visibility boundary stops helping as soon as any part of that Turn + becomes relevant. Keep the stable timeline blocks inside it independently + skippable so Chromium does not lay out and paint every Markdown, reasoning, + and tool subtree while the reader crosses one nearby block. + + Renderers apply the marker at the source of each timeline block, including + the children of a Processing fold. `auto` retains the measured block size + after first paint, preserving native scroll + anchoring when a skipped block leaves and re-enters the viewport. */ +.maka-chat-message-list [data-maka-transcript-boundary] { + content-visibility: auto; + /* First-paint intrinsic-size ESTIMATE for scroll-anchor stability, not a + fixed height: `auto px` still grows to the block's real size after + paint. 96px is the single-line answer baseline; tall multi-line blocks + override it below. */ + contain-intrinsic-block-size: auto 96px; +} + +/* Container blocks — a Processing sequence, a linked-agent list — hold many + entries, so their first-paint estimate stays multi-line. It remains an + estimate rather than a clamp: the block grows to its measured size after + paint. */ +.maka-chat-message-list [data-maka-transcript-boundary="large"] { + contain-intrinsic-block-size: auto 320px; +} + +/* Collapsed disclosures are the exception among the "large" sites: a folded + reasoning run or tool/activity card renders as one summary row measuring + 24–32px, and a 320px estimate materializes as a ~290px collapse under a + reader scrolling up cold — the anchor-jump the cold-scroll story gates. + Estimate them at their collapsed height; `auto` still remembers the real + expanded size once a reader opens one. */ +.maka-chat-message-list :is(.maka-deep-thinking, .maka-tool-activity-card)[data-maka-transcript-boundary='large'] { + contain-intrinsic-block-size: auto 32px; +} + /* Expanded activity headers stay reachable while their own detail is being read. Native sticky positioning keeps the header in the transcript flow, so it leaves naturally at the card boundary and does not disturb ChatLayout diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 66c84b7cdf..198dc45b66 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1861,6 +1861,138 @@ export const TailFollowDoesNotAskForHistory: Story = { }, }; +// #4256: one Turn taller than several viewports, its reasoning / answer / tool +// blocks each carrying a `data-maka-transcript-boundary` marker so sub-turn +// content-visibility bounds them. Reasoning stays mounted while folded, so it is +// real layout, not free collapsed bytes. +function oversizedTurnMessages(): StoredMessage[] { + const turnId = 'turn-oversized'; + const out: StoredMessage[] = [ + user('msg-oversized-user', turnId, 30, '逐项检查一组独立的合成步骤,并给出简短结果。'), + ]; + const prose = '这一段只包含确定性的合成文本,用于测量长对话的滚动渲染。'.repeat(8); + const reasoning = '先确认输入边界(空 / 超长 / 并发),再对合成输出做一次去抖动检查,确保占位高度不随展开态漂移。'.repeat(4); + for (let step = 1; step <= 24; step += 1) { + const ts = NOW - (25 - step) * 20_000; + out.push({ + type: 'assistant', + id: `msg-oversized-a-${step}`, + turnId, + ts, + text: `### 合成步骤 ${step}\n\n${prose}`, + // The first line becomes the disclosure button's accessible name, so it + // carries the step number — identical names across materialized steps + // read as indistinguishable controls to the AX audit. + thinking: { text: `第 ${step} 组边界检查\n\n${reasoning}\n\n${reasoning}` }, + modelId: 'claude-sonnet-4-5', + }); + out.push({ + type: 'tool_call', + id: `tool-oversized-${step}`, + turnId, + ts: ts + 1_000, + toolName: 'Bash', + displayName: `合成检查 ${step}`, + intent: `读取第 ${step} 组固定测试数据`, + stepId: `msg-oversized-a-${step}`, + args: { cmd: `fixture-check --step ${step}` }, + }); + out.push({ + type: 'tool_result', + id: `tool-oversized-r-${step}`, + turnId, + ts: ts + 2_000, + toolUseId: `tool-oversized-${step}`, + isError: false, + durationMs: 100 + step, + content: { kind: 'text', text: `第 ${step} 组:确定性、可重放,无回归。` }, + }); + } + return out; +} + +const oversizedTurn = oversizedTurnMessages(); + +export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { + render: () => , + play: async () => { + const root = tailScroller(); + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + // A single Turn taller than several viewports is the point; without the + // overflow the rest proves nothing. + expect( + root.scrollHeight, + JSON.stringify(tailMetrics()), + ).toBeGreaterThan(root.clientHeight * 3); + + // The visible block nearest the middle of the scrollport, re-chosen each + // step so it is always one the reader can actually see. + const visibleAnchor = (): HTMLElement => { + const rootRect = root.getBoundingClientRect(); + const center = (rootRect.top + rootRect.bottom) / 2; + const anchor = [...root.querySelectorAll('[data-maka-transcript-boundary]')] + .filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.bottom > rootRect.top && rect.top < rootRect.bottom; + }) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return Math.abs((leftRect.top + leftRect.bottom) / 2 - center) + - Math.abs((rightRect.top + rightRect.bottom) / 2 - center); + })[0]; + if (!anchor) throw new Error('no visible reading anchor'); + return anchor; + }; + + // Cold: no warmup pass has rendered the blocks above, so each upward step + // materializes first-paint intrinsic-size estimates. The criterion is what + // the reader sees, so it is measured in viewport space: an anchor they were + // reading should move down by exactly the step they asked for. Native + // `overflow-anchor` compensates the materialization by adjusting + // `scrollTop`, so neither document-space growth nor the scrollTop delta may + // be the yardstick — comparing against either reports the (allowed) + // correction itself as a jump. Only `|viewport move − intended step|` is a + // jump the reader experiences. + let worstUnexpected = 0; + const steps: Array> = []; + for (let step = 0; step < 8 && root.scrollTop > 0; step += 1) { + const anchor = visibleAnchor(); + const topBefore = anchor.getBoundingClientRect().top; + const intended = Math.min(240, root.scrollTop); + const heightBefore = root.scrollHeight; + // `behavior: 'instant'` overrides the shell's smooth scrolling: the shell + // animates over many frames, and a step measured before the animation + // lands reads a still anchor as a 240px jump. + root.scrollTo({ top: root.scrollTop - intended, behavior: 'instant' }); + root.dispatchEvent(new Event('scroll')); + await painted(4); + const moved = anchor.getBoundingClientRect().top - topBefore; + worstUnexpected = Math.max(worstUnexpected, Math.abs(moved - intended)); + steps.push({ + step, + intended, + moved: Math.round(moved), + scrollTop: Math.round(root.scrollTop), + grewBy: root.scrollHeight - heightBefore, + }); + } + // On main this story reads 0 by construction — no sub-turn boundary exists + // to materialize. On this branch the error tracks materialization exactly: + // a zero-growth step read 0px, and with the folded-disclosure estimate at + // 320px against a 24–32px collapsed row, steps measured up to 244px — a + // reader-visible stall of a 240px scroll step. With the collapsed estimate + // corrected, the residual is the answer blocks' estimate error, which stays + // well under half a step. The bound is half a step: loose enough for + // per-run variance, tight enough that a stalled or reversed step can never + // pass again. + expect( + worstUnexpected, + `worst unexpected reading-anchor move: ${Math.round(worstUnexpected)}px; steps: ${JSON.stringify(steps)}`, + ).toBeLessThanOrEqual(120); + }, +}; + export const AWheelTheScrollerCannotActOnAsksForHistory: Story = { render: () => , play: async () => { diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 574e60548c..dbc9ac82e9 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -28,6 +28,7 @@ export type E2eFixtureScenario = | 'turn-narrative-browser' | 'chat-prompt-rail' | 'chat-partial-history' + | 'chat-oversized-turn' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 69cc381b8e..e25e695637 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1189,6 +1189,7 @@ const AssistantAnswerBubble = memo(function AssistantAnswerBubble(props: Assista return ( +
{entries.map((entry, index) => ( ) : ( @@ -429,7 +430,7 @@ function LinkedAgentList(props: { const activityCopy = getToolActivityCopy(props.locale); const copy = activityCopy.agent; return ( - + {props.rows.map((row) => { const childSessionId = row.childSessionId; const open = childSessionId && props.onOpenLinkedSession