diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 53020800d5..7d438274b5 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, + }); } } } @@ -572,6 +579,7 @@ type E2eTestFixtures = { railRenderWindow: Page; promptRailWindow: Page; partialHistoryWindow: Page; + oversizedTurnWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; @@ -763,6 +771,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 08dc5b226d..848b67aa58 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', @@ -189,6 +193,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': @@ -274,6 +280,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..d2509c773d 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,85 @@ 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); + 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)}`, + 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 906182d998..d6040bdea0 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 = process.env.MAKA_TRANSCRIPT_STRESS === '1' ? 640 diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index ebe039fd44..1da8f7f140 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,15 +42,13 @@ width: 100%; } -/* Inserting earlier turns above the reader must not move what they are - reading. The browser's scroll anchoring does exactly that, so state the - dependency on the scroller that runs it rather than inheriting the `auto` - default: Maka reads no geometry and restores no position of its own. The - one case anchoring declines is a scroller sitting at zero, compensated in - useChatScroll after the turns land. */ -[data-chat-scroll-container='true'] { - overflow-anchor: auto; -} +/* Inserting earlier turns above the reader must not move what they are reading. + The browser's scroll anchoring does exactly that — but only once the reader + has left the tail. While the tail is pinned, anchoring is a second writer of + `scrollTop` whose adjustments reach the scroll authority as events it cannot + tell from the reader (#4269), so the pin owns `overflow-anchor` directly: + `transcript-scroll-authority` sets `none` while following the tail and hands + the default back on release. No static rule here, or it would fight that. */ .maka-transcript-turn { display: flex; @@ -123,6 +121,35 @@ 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; +} + +/* Reasoning runs, Processing children, linked-agent lists, and tool/activity + cards are routinely multi-line. Their 320px first-paint estimate lets native + overflow anchoring absorb more of the intrinsic-size correction. 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; +} + /* 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/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/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 9901780cb0..7c65161c15 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -36,6 +36,8 @@ interface FakeRoot { scrollTop: number; scrollHeight: number; clientHeight: number; + /** The pin owns `overflow-anchor`, so the authority writes it here. */ + style: { overflowAnchor: string }; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; addEventListener(type: string, listener: () => void): void; @@ -53,6 +55,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, + style: { overflowAnchor: '' }, children: [{}], addEventListener(type, listener) { if (type === 'scroll') listeners.add(listener); @@ -231,26 +234,30 @@ test('a scroll event that arrives late is still this authority\'s own write', () }); }); -test('growth that outruns the write does not read as the reader scrolling up', () => { +test('a reader scroll under changing geometry still releases the tail', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The transcript grew, and the scroll event for it arrives before this - // authority has been told to follow it. The offset is 302px from a tail - // that moved — identical, as a position, to a reader who scrolled up. - root.grow(302); - root.scrollTop = 2_402; + // #4269: the reader scrolls up while the turn is still streaming, so the + // very gesture that moves the offset up also grows `scrollHeight` as + // content-visibility placeholders expand. The event is `moved`, yet the + // offset is now above this authority's last write — the one thing growth + // alone never produces — so it is the reader, and the pin releases even + // though the geometry changed in the same frame. + root.grow(500); + root.scrollTop = 1_000; root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - // The affordance still knows how far the tail now is, and the next growth - // signal takes the reader back to it. + assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); + + // Released, this authority writes nothing more; the reader keeps their + // place as the turn keeps growing. + root.grow(4_000); resize(); - assert.equal(root.scrollTop, 2_702); + assert.equal(root.scrollTop, 1_000); }); }); @@ -292,13 +299,8 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { root.emitScroll(); assert.equal(heard, 0); - // Content arriving, with anchoring moving the offset to hold the reader. - root.grow(500); - root.scrollTop = 2_900; - root.emitScroll(); - assert.equal(heard, 0); - - // The reader, at last. + // The reader moves the offset the authority did not write. With anchoring + // off under the pin, this is the only thing a non-echo event can be. root.scrollTop = 900; root.emitScroll(); assert.equal(heard, 1); @@ -309,3 +311,32 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { assert.equal(heard, 1); }); }); + +test('the pin owns overflow anchoring, off while pinned and back on release', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + + // Pinned from the start: the browser must not anchor under a following tail, + // or its adjustments arrive as scroll events indistinguishable from the + // reader — the #4269 snap-back. + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.style.overflowAnchor, 'none'); + + // The reader leaves the tail; anchoring is handed back so content landing + // above holds their place. + root.scrollTop = 900; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(root.style.overflowAnchor, ''); + + // Returning to the tail re-pins and takes anchoring back. + authority.pinToTail(); + assert.equal(root.style.overflowAnchor, 'none'); + + // Detaching restores the browser default. + detach(); + assert.equal(root.style.overflowAnchor, ''); + }); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 1ff7db9a17..2054fbd23c 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1188,6 +1188,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 diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index e2a20e5b0a..1e325b148b 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -29,9 +29,15 @@ * !pinned → nothing here writes `scrollTop`, ever * * "Keep the reader where they were reading" is the definition of - * `overflow-anchor: auto`, which is already the initial value and costs nothing, - * and "the reader is dragging" is also just don't touch it — so both of those - * are the same instruction to this code: stay out of the way. + * `overflow-anchor`, and once the reader has left the tail that is exactly the + * behaviour this code wants — so on release it hands anchoring back to the + * browser and stays out of the way. While pinned it does the opposite and turns + * anchoring off, because a second writer moving `scrollTop` under a following + * tail is not help: its adjustments are overwritten on the next frame, but the + * `scroll` events they emit are indistinguishable from the reader, and the + * guard would swallow a real upward gesture along with them (#4269). The pin + * owns anchoring, so while it is set this authority is genuinely the only + * writer. * * Being the only writer is what makes the state exact rather than guessed. It * remembers the offset it wrote, so a scroll event that finds the scroller @@ -135,6 +141,13 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); }; + // The pin owns overflow anchoring. Off while following the tail so the browser + // is not a competing writer; handed back on release so a reader who left the + // tail keeps their place as turns land above them. + const ownAnchoring = (): void => { + if (root) root.style.overflowAnchor = pinned ? 'none' : ''; + }; + return { attach(next) { root = next; @@ -152,26 +165,26 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { lastClientHeight = target.clientHeight; return; } - // An event that arrives with the scroll geometry changed is the content - // or the viewport moving under the reader, not the reader moving: - // anchoring holding them still as turns land above, growth that outran - // this authority's own write, or a resize the browser answered by - // clamping the offset. Their offset changed and their intent did not, - // so the pin — which is that intent — must not be re-derived from where - // they now are, and nobody may be told the reader asked for anything. - // The affordance still follows the new distance, because that is a fact - // about the viewport rather than about them. + // While pinned, anchoring is off — the pin owns it — so no second writer + // moves the offset: any event that is not this authority's echo is the + // reader, and reading their position is exact even when growth changed + // the geometry in the same frame. Once released, anchoring is back and a + // geometry-changing event is the content or the viewport moving under a + // reader this code no longer follows, not a re-pin signal — so a `moved` + // event while unpinned is left alone. Only a stable-geometry event, or + // the reader arriving back at the tail, moves the pin. const moved = target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; const distance = distanceToTail(); awayFromTail = distance > BUTTON_THRESHOLD_PX; - if (moved) { + if (moved && !pinned) { publish(); return; } pinned = distance <= PIN_THRESHOLD_PX; + ownAnchoring(); publish(); for (const listener of [...readerListeners]) listener(); }; @@ -207,22 +220,28 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { const childList = new MutationObserver(observeBox); childList.observe(target, { childList: true }); observeBox(); + ownAnchoring(); if (pinned) writeToTail(); return () => { childList.disconnect(); box.disconnect(); target.removeEventListener('scroll', onScroll); + // Hand anchoring back to the browser: the pin no longer owns this + // scroller, so its default `overflow-anchor` must be restored. + target.style.overflowAnchor = ''; lastWrittenTop = undefined; if (root === target) root = null; }; }, pinToTail() { pinned = true; + ownAnchoring(); writeToTail(); publish(); }, releasePin() { pinned = false; + ownAnchoring(); awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); },