Просмотр исходного кода

Merge pull request #1170 from deepseek-harness/sticky-collapsible-headers

feat(web): pin Think and compaction headers sticky while scrolling
Chinesezjc 4 дней назад
Родитель
Сommit
c75e3d4e81

+ 6 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
+2026-08-03-web-sticky-collapsible-headers.md: bcb4a8c2e889ab5a02e37b87c08f863120a3a889
+2026-08-03-web-sticky-collapsible-headers.zh.md: 3cad851ddea1dfd6b08715435b7bbd2ee7a084e3

+ 39 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md

@@ -0,0 +1,39 @@
+# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling
+
+Status: implemented
+
+English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md)
+
+## Problem
+
+Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle.
+
+## Decision
+
+The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row.
+
+Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header.
+
+The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`); the compaction header therefore uses `z-index: 7`, so a code-block summary cannot let that banner pin over the toggle and hide it. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle to collapse it. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. The composer seat's own `z-index: 7` shares the rank; where the composer overlaps the pinned header on a very short viewport, the composer wins by DOM order.
+
+The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state.
+
+No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components.
+
+## Alternatives considered
+
+**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable.
+
+**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency.
+
+**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block.
+
+## Consequences
+
+The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets.
+
+## Testing
+
+The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the two rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the `z-index` rank of each side, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration.
+
+The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable, not buried under the banner), and that its hover fill stays fully opaque (alpha 1). The same case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls.

+ 39 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md

@@ -0,0 +1,39 @@
+# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住
+
+Status: implemented
+
+[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文
+
+## 问题
+
+会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。
+
+## 决策
+
+每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。
+
+这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。
+
+钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`);因此压缩标题用 `z-index: 7`,让含代码块的摘要不会让那个 banner 盖在折叠按钮之上、把它藏住。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。输入框座自身也占 `z-index: 7`;在极矮视口上二者重叠时,输入框座按 DOM 序胜出。
+
+规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。
+
+不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。
+
+## 曾考虑的替代方案
+
+**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。
+
+**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。
+
+**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。
+
+## 后果
+
+很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。
+
+## 测试
+
+单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、两侧各自的 `z-index` 级别,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。
+
+真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击,没被 banner 埋掉),以及其 hover 底保持完全不透明(alpha 1)。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。

+ 40 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -23,7 +23,7 @@ import {
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import {
-  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
+  connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
@@ -343,6 +343,45 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('pins an open Think header to the conversation scrollport (real layout)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-think-sticky'))
+    // The settled turn collapses its process row, which hides the Think row.
+    // The fixture's recorded reasoning is one line, too short to overflow the
+    // scrollport, so this case proves the CSS resolves onto the Think header in
+    // a real browser (jsdom computes no sticky layout); the pinned-while-
+    // scrolling and z-rank evidence belongs to the compaction path in
+    // seeded-history.e2e.ts, whose summary length that suite controls.
+    const thinkRow = page.locator('[data-variant="think"]').first()
+    await thinkRow.waitFor({ state: 'attached', timeout: 15_000 })
+    const process = page.locator('[data-turn-process]').first()
+    const processWasOpen = await process.getAttribute('aria-expanded') === 'true'
+    try {
+      await expandOwningTurnProcess(page, thinkRow)
+      const collapsedHeader = thinkRow.locator('[data-disclosure-row]').first()
+      await collapsedHeader.waitFor({ timeout: 10_000 })
+      // Collapsed, the rule's `data-open` gate is absent and the header stays in
+      // flow. It is `relative` here — the row is the sweep-glare overlay anchor
+      // — so the assertion is the absence of `sticky`, not a specific value.
+      expect(await collapsedHeader.evaluate(element => getComputedStyle(element).position)).not.toBe('sticky')
+      await collapsedHeader.click()
+      const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]').first()
+      await openHeader.waitFor({ timeout: 10_000 })
+      const openStyle = await openHeader.evaluate((element) => {
+        const style = getComputedStyle(element)
+        return { position: style.position, top: style.top }
+      })
+      expect(openStyle.position).toBe('sticky')
+      expect(openStyle.top).toBe('0px')
+    } finally {
+      // Restore the settled state the reload goldens below are captured in.
+      const openThinkRow = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]')
+      if (await openThinkRow.count() > 0) await openThinkRow.first().click()
+      if (!processWasOpen && await process.getAttribute('aria-expanded') === 'true') await process.click()
+    }
+    await expect.poll(() => page.locator('[data-variant="think"] [data-open]').count(), { timeout: 5_000 }).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
     const warningStart = tripwire.warnings.length

+ 165 - 12
apps/web/tests/seeded-history.e2e.ts

@@ -39,6 +39,13 @@ const UI_EXPANDED_EXPECTED = fileURLToPath(
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
 const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md')
+// The pinned-header geometry golden: a pure-CSS, user-visible behavior that
+// changes no DOM and no accessible name, so the aria goldens cannot capture it
+// (docs/testing.md, "when a snapshot test is required", still requires a
+// keyless snapshot). Following composer-draft-scroll's geometry golden, it
+// records platform-independent semantic booleans about the pinned compaction
+// header, no absolute pixels.
+const STICKY_GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'sticky-geometry.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'seeded-history-web-e2e'
 
@@ -137,7 +144,14 @@ function withCompaction(raw: string, meter: TokenMeter): string {
       sourceCommandId: commandId,
       summary: [{
         type: 'text',
-        text: '## Cold resume compact summary\n\n- The exact summary remains available.',
+        text: '## Cold resume compact summary\n\n- The exact summary remains available.\n\n'
+          // A fenced code block gives the summary body a sticky-bannered
+          // descendant (CodeBlock pins its banner at z-index 6); the pinned
+          // compaction header must outrank it, so the summary carries one to
+          // give the hit-test below a real target. The list makes the body
+          // overflow the shrunk viewport.
+          + '```ts\nfunction resume() { return true }\n```\n\n'
+          + Array.from({ length: 40 }, (_, index) => `- Retained fact ${index + 1}: the reader still sees the pre-compaction surface.`).join('\n'),
       }],
       shadowedRange: { start: first, end: last },
       shadowedSeqs: surfaceSeqs,
@@ -437,20 +451,159 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
   })
 
-  it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
+  it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
     const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
     await marker.waitFor({ timeout: 10_000 })
     expect(await marker.getAttribute('aria-expanded')).toBe('false')
-    await marker.click()
-    await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
-    await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
-      timeout: 5_000,
-    }).toBe(1)
-    expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
-    // Restore the shared page state for any later case.
-    await marker.click()
-    await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+    // Collapsed, the marker is not pinned: the sticky rule's `:has()` gate
+    // needs the body sibling, which only exists while open. jsdom computes no
+    // sticky layout, so this real-browser layer proves the CSS resolves.
+    const collapsedPosition = await marker.evaluate(element => getComputedStyle(element).position)
+    expect(collapsedPosition).not.toBe('sticky')
+    const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
+    try {
+      await marker.click()
+      await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
+      await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
+        timeout: 5_000,
+      }).toBe(1)
+      expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
+      // Open, the toggle pins to the scroll container's top.
+      const openStyle = await marker.evaluate((element) => {
+        const style = getComputedStyle(element)
+        return { position: style.position, top: style.top, zIndex: Number.parseInt(style.zIndex, 10) }
+      })
+      expect(openStyle.position).toBe('sticky')
+      expect(openStyle.top).toBe('0px')
+      // The summary body carries a fenced code block whose own banner pins at
+      // z-index 6; the toggle must outrank it, or a code-block summary would
+      // re-bury the toggle. Sample the banner inside THIS summary body, not a
+      // code block elsewhere on the page.
+      const bannerZ = await page.locator('[class*="compactionBody"] [class*="bannerWrap"]').first().evaluate(
+        element => Number.parseInt(getComputedStyle(element).zIndex, 10),
+      )
+      expect(openStyle.zIndex).toBeGreaterThan(bannerZ)
+      // Hovering the open toggle must keep an OPAQUE fill: the default hover
+      // token is translucent and would let the scrolling prose bleed through
+      // the moment the pointer lands to collapse it. The alpha token, if
+      // present, is the fourth comma value (`rgba(r, g, b, a)`) or the value
+      // after `/` in the space form; three channels mean opaque. A color that
+      // parses to neither returns -1, which fails loud instead of passing as
+      // opaque.
+      await marker.hover()
+      const hoverAlpha = await marker.evaluate((element) => {
+        const bg = getComputedStyle(element).backgroundColor
+        const inner = /^rgba?\((.+)\)$/.exec(bg.trim())?.[1]
+        if (inner === undefined) return -1
+        const slashAlpha = inner.split('/')[1]
+        if (slashAlpha !== undefined) return Number.parseFloat(slashAlpha)
+        const channels = inner.split(/[\s,]+/).filter(token => token.length > 0)
+        const commaAlpha = channels[3]
+        if (commaAlpha !== undefined) return Number.parseFloat(commaAlpha)
+        if (channels.length === 3) return 1
+        return -1
+      })
+      expect(hoverAlpha).toBe(1)
+      // Scroll the code block up until its banner covers the pinned header's
+      // own CENTER point, then prove the header (not the banner) is the topmost
+      // element there. A shallower scroll that lands the banner merely tangent
+      // to the header's bottom edge leaves the center clear, so the hit-test
+      // could not tell a correct z-rank from a broken one. Shrinking the
+      // viewport first forces overflow regardless of summary length.
+      await page.setViewportSize({ width: originalViewport.width, height: 360 })
+      const geom = await marker.evaluate((button) => {
+        const container = button.closest('[data-conversation-scroll]') as HTMLElement
+        const banner = container.querySelector('[class*="compactionBody"] [class*="bannerWrap"]') as HTMLElement
+        // Both the toggle and the code banner are sticky at top 0, so a rect
+        // taken while either is stuck reports the stuck position rather than its
+        // content offset. Measure both unstuck, so the target below does not
+        // depend on where the scrollport happened to be when this case started.
+        const markerInline = button.style.position
+        const bannerInline = banner.style.position
+        const bannerTopInline = banner.style.top
+        button.style.position = 'static'
+        banner.style.position = 'static'
+        banner.style.top = 'auto'
+        const containerTop = container.getBoundingClientRect().top
+        const markerStaticTop = button.getBoundingClientRect().top - containerTop + container.scrollTop
+        const bannerStaticTop = banner.getBoundingClientRect().top - containerTop + container.scrollTop
+        const headerHeight = button.getBoundingClientRect().height
+        button.style.position = markerInline
+        banner.style.position = bannerInline
+        banner.style.top = bannerTopInline
+        // Pinned, the header's center sits at containerTop + headerHeight/2.
+        // Scroll the banner's static top a few px above that center line, so
+        // the banner spans the point the hit-test samples.
+        container.scrollTop = Math.max(0, bannerStaticTop - headerHeight / 2 + 4)
+        const markerRect = button.getBoundingClientRect()
+        const bannerRect = banner.getBoundingClientRect()
+        const centerX = markerRect.left + markerRect.width / 2
+        const centerY = markerRect.top + markerRect.height / 2
+        const probe = document.elementFromPoint(centerX, centerY)
+        return {
+          scrollTop: container.scrollTop,
+          // The header's own content offset now lies above the scrollport top,
+          // so its rect top can equal the scrollport top only through stickiness
+          // — this is the precondition that makes the pinning assertion mean
+          // something.
+          staticAboveViewport: container.scrollTop > markerStaticTop,
+          markerTop: markerRect.top,
+          containerTop: container.getBoundingClientRect().top,
+          // The banner must span the sampled point on BOTH axes, or the
+          // hit-test there proves nothing about the z-rank.
+          bannerCoversCenter: bannerRect.top <= centerY && bannerRect.bottom >= centerY
+            && bannerRect.left <= centerX && bannerRect.right >= centerX,
+          markerOwnsCenter: button.contains(probe),
+        }
+      })
+      expect(geom.scrollTop).toBeGreaterThan(0)
+      expect(geom.staticAboveViewport).toBe(true)
+      expect(Math.abs(geom.markerTop - geom.containerTop)).toBeLessThanOrEqual(1)
+      expect(geom.bannerCoversCenter).toBe(true)
+      expect(geom.markerOwnsCenter).toBe(true)
+      // Keyless geometry golden for this user-visible, DOM-invariant CSS
+      // behavior: platform-independent semantic facts, no absolute pixels.
+      // Every line is a value asserted just above, so a regression reddens the
+      // expect first; compareOrRefreshGolden writes the file in refresh mode
+      // and byte-compares it in replay.
+      const stickyGolden = [
+        '# Compaction marker sticky header (pinned over a code-block summary)',
+        '',
+        '## Collapsed',
+        '',
+        `- header is not sticky: ${String(collapsedPosition !== 'sticky')}`,
+        '',
+        '## Open, pinned at the scroll container top',
+        '',
+        `- header position is sticky: ${String(openStyle.position === 'sticky')}`,
+        `- header pins to the top edge: ${String(openStyle.top === '0px')}`,
+        `- header outranks the summary code-block banner: ${String(openStyle.zIndex > bannerZ)}`,
+        `- hover fill stays fully opaque: ${String(hoverAlpha === 1)}`,
+        '',
+        '## Scrolled so the code-block banner overlaps the header center',
+        '',
+        `- container is scrolled off its top: ${String(geom.scrollTop > 0)}`,
+        `- header's static position sits above the scrollport: ${String(geom.staticAboveViewport)}`,
+        `- header holds at the scrollport top: ${String(Math.abs(geom.markerTop - geom.containerTop) <= 1)}`,
+        `- banner spans the sampled center point: ${String(geom.bannerCoversCenter)}`,
+        `- header owns the center point (toggle stays clickable): ${String(geom.markerOwnsCenter)}`,
+      ].join('\n').trimEnd()
+      await compareOrRefreshGolden(STICKY_GEOMETRY_EXPECTED, stickyGolden, MODE)
+    } finally {
+      // Restore the shared page state even if an assertion above threw. Order
+      // matters: collapse the marker, restore the viewport, then re-enter
+      // follow-bottom through the control's own handler. Assigning scrollTop
+      // does not restore ownership: reader movement stays pending until the
+      // sampling interval or `scrollend`, so the control would leak into later
+      // aria goldens.
+      if (await marker.getAttribute('aria-expanded') === 'true') await marker.click()
+      await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
+      await page.setViewportSize(originalViewport)
+      const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
+      if (await backToBottom.count() > 0) await backToBottom.click()
+      await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
+    }
   })
 
   it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
@@ -546,7 +699,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
-      'session.v3.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+      'session.v3.jsonl', 'sticky-geometry.expected.md', 'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })
 })

+ 21 - 0
packages/client/ui-chat/src/client/chat/MessageItem.module.css

@@ -71,6 +71,27 @@
   text-align: left;
 }
 
+/* The summary body flows uncapped with the page, so a long checkpoint summary
+   carries this toggle off the top of the viewport. Pin it to the conversation
+   scroll container's top while the row is open (the body is a sibling rendered
+   only when expanded); the base background masks the prose scrolling under it.
+   z-index outranks the markdown code block's own sticky banner (z-index 6 in
+   CodeBlock.module.css), which would otherwise pin over this toggle and hide
+   it once a summary's fenced code block scrolls into the header band. */
+.compactionRow:has(.compactionBody) .compactionButton {
+  position: sticky;
+  top: 0;
+  z-index: 7;
+  background: var(--dsw-alias-bg-base);
+}
+
+/* Pinned, the toggle needs an opaque hover fill: the default translucent hover
+   token below would let the scrolling prose show through the moment the pointer
+   lands on the toggle to collapse it. */
+.compactionRow:has(.compactionBody) .compactionButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover-solid);
+}
+
 .compactionButton:not(:disabled) {
   cursor: pointer;
 }

+ 16 - 0
packages/client/ui-chat/src/client/chat/ReasoningRow.module.css

@@ -13,6 +13,22 @@
   overflow: hidden;
 }
 
+/* Think's body flows uncapped with the page (unlike the capped tool cards,
+   which scroll inside their own card), so a long reasoning chain carries the
+   disclosure header off the top of the viewport and buries the collapse toggle.
+   Pin the header to the conversation scroll container's top while the row is
+   open so the toggle stays reachable; the base background masks the prose
+   scrolling under it. Gated on DisclosureRow's `data-open` so a collapsed row
+   scrolls away normally. The body is plain text with no sticky descendant, so
+   any positive z-index clears the flow; the compaction marker, whose markdown
+   body can pin a code-block banner, needs a higher rank. */
+.root[data-expanded] [data-open] [data-disclosure-row] {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  background: var(--dsw-alias-bg-base);
+}
+
 .root[data-state='running'] .row::after {
   content: '';
   position: absolute;

+ 18 - 0
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -830,6 +830,24 @@ describe('MessageItem arms', () => {
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
 
+  it('anchors the sticky-header selector: the compaction body sits under compactionRow only while open', () => {
+    const view = render(
+      <MessageItem t={t} node={{
+        kind: 'compaction', seq: 5, time: 1_000,
+        summary: '## 摘要标题\n\n保留的事实。',
+        summaryEventSeq: 4,
+        shadowedItemCount: 16,
+        shadowedTokenCount: 11_309,
+      }}
+      />,
+    )
+    // Collapsed there is no body sibling, so the rule's `:has(.compactionBody)`
+    // gate never matches.
+    expect(view.container.querySelector('[class*="compactionRow"] [class*="compactionBody"]')).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: /上下文已压缩/ }))
+    expect(view.container.querySelector('[class*="compactionRow"] [class*="compactionBody"]')).not.toBeNull()
+  })
+
   it('a marker whose cited summary event fell outside the window is not expandable', () => {
     const view = render(<MessageItem t={t} node={{
       kind: 'compaction', seq: 6, time: 1_000, summary: null,

+ 22 - 0
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -138,4 +138,26 @@ describe('ReasoningRow', () => {
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
   })
+
+  it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => {
+    const view = render(
+      <AssistantMarkdown
+        t={t}
+        blocks={[
+          { kind: 'reasoning', text: 'Inspect the session\nCheck persistence' },
+          { kind: 'text', text: 'Answer' },
+        ]}
+        streaming={false}
+        renderMessageImages={renderMessageImages}
+      />,
+    )
+    // Collapsed: no `data-open`, so the sticky rule's gate never matches.
+    expect(view.container.querySelector('[data-variant="think"] [data-open]')).toBeNull()
+    fireEvent.click(view.getByText('思考'))
+    expect(
+      view.container.querySelector(
+        '[data-variant="think"][data-expanded] [data-open] [data-disclosure-row]',
+      ),
+    ).not.toBeNull()
+  })
 })

+ 51 - 0
packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts

@@ -0,0 +1,51 @@
+/**
+ * The two pinned collapsible headers as CSS text. jsdom has no layout, so the
+ * rendering specs pin which DOM anchors the selectors key on but cannot show
+ * whether the pinning declarations resolve; these read the declarations the
+ * pinning and the stacking rank depend on.
+ */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const read = (name: string): string =>
+  readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8')
+
+function declarationsFrom(source: string, selector: string): string[] {
+  const declarationText = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
+  const rule = new RegExp(`(?:^|[{}])\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText)
+  if (rule === null) throw new Error(`no \`${selector}\` rule`)
+  return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
+}
+
+describe('pinned collapsible headers', () => {
+  it('pins an open Think header to the scrollport top and masks the prose under it', () => {
+    expect(
+      declarationsFrom(read('ReasoningRow.module.css'), '.root[data-expanded] [data-open] [data-disclosure-row]'),
+    ).toEqual(expect.arrayContaining([
+      'position: sticky',
+      'top: 0',
+      'z-index: 1',
+      'background: var(--dsw-alias-bg-base)',
+    ]))
+  })
+
+  it('ranks the pinned compaction header above the code-block banner', () => {
+    expect(
+      declarationsFrom(read('MessageItem.module.css'), '.compactionRow:has(.compactionBody) .compactionButton'),
+    ).toEqual(expect.arrayContaining([
+      'position: sticky',
+      'top: 0',
+      // CodeBlock.module.css pins its banner at 6; a lower rank here would let
+      // a summary's fenced code block cover the toggle.
+      'z-index: 7',
+      'background: var(--dsw-alias-bg-base)',
+    ]))
+  })
+
+  it('keeps the pinned compaction header opaque under hover', () => {
+    expect(
+      declarationsFrom(read('MessageItem.module.css'), '.compactionRow:has(.compactionBody) .compactionButton:hover'),
+    ).toEqual(expect.arrayContaining(['background: var(--dsw-alias-interactive-bg-hover-solid)']))
+  })
+})

+ 6 - 1
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -370,7 +370,12 @@
   position: sticky;
   bottom: 0;
   /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
-     paints under a sticking code header while scrolling. */
+     paints under a sticking code header while scrolling. The pinned compaction
+     header (MessageItem.module.css, ui-chat) and the turn-navigation rail slot
+     (TurnNavigator.module.css, ui-chat) also take 7 to clear the same banners.
+     Among elements of equal rank the later one in DOM order wins, so the
+     composer stays above the pinned header on the extreme small-viewport
+     overlap and the pinned header stays above the rail. */
   z-index: 7;
   /* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
      band at the seat's top (the figma 24% of the resting ~150px composer),

+ 20 - 0
snapshots/web/seeded-history/sticky-geometry.expected.md

@@ -0,0 +1,20 @@
+# Compaction marker sticky header (pinned over a code-block summary)
+
+## Collapsed
+
+- header is not sticky: true
+
+## Open, pinned at the scroll container top
+
+- header position is sticky: true
+- header pins to the top edge: true
+- header outranks the summary code-block banner: true
+- hover fill stays fully opaque: true
+
+## Scrolled so the code-block banner overlaps the header center
+
+- container is scrolled off its top: true
+- header's static position sits above the scrollport: true
+- header holds at the scrollport top: true
+- banner spans the sampled center point: true
+- header owns the center point (toggle stays clickable): true