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 419f92dc26..3283cbb49a 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2164,6 +2164,146 @@ 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 containment claim itself, in the same Chromium the app ships: + // offscreen timeline blocks are genuinely skipped, not merely marked. + // (This carries the deleted Electron spec's assertion — #4825 moved this + // tier of coverage below Electron.) + const skipped = [...root.querySelectorAll('[data-maka-transcript-boundary]')] + .filter((element) => !element.checkVisibility({ contentVisibilityAuto: true })) + .length; + expect(skipped, 'no offscreen boundary is skipped').toBeGreaterThan(0); + + // 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/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