Ver Fonte

Merge pull request #4195 from deepseek-harness/fix/compaction-banner-offset

fix(web): hold a compaction summary's code banner below the pinned header
Chinesezjc há 2 semanas atrás
pai
commit
64a8ba929d

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

@@ -2,5 +2,5 @@
 # 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
+2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52
+2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360

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

@@ -14,7 +14,7 @@ The disclosure header of each uncapped block sticks to the conversation scroll c
 
 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 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`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
 
 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.
 
@@ -26,6 +26,8 @@ No session, wire, durable event, or model-visible contract changes; this is a pr
 
 **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.
 
+**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling.
+
 **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
@@ -34,6 +36,6 @@ The collapse toggle for a long Think block or compaction summary stays reachable
 
 ## 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 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 rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, 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.
+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), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The 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.

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

@@ -14,7 +14,7 @@ Status: implemented
 
 这两个块本来就是相对共享的会话滚动容器(`[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 序胜出。
+钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
 
 规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 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()` 就以展开状态门控钉住。
 
@@ -26,6 +26,8 @@ Status: implemented
 
 **为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。
 
+**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。
+
 **在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。
 
 ## 后果
@@ -34,6 +36,6 @@ Status: implemented
 
 ## 测试
 
-单元测试钉住选择器所依赖的 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 布局,渲染类测试也无法在某条声明被改动时变红。
+单元测试钉住选择器所依赖的 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` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 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 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。
+真实浏览器证据由两条 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、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。

+ 76 - 40
apps/web/tests/seeded-history.e2e.ts

@@ -146,11 +146,13 @@ function withCompaction(raw: string, meter: TokenMeter): string {
         type: 'text',
         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'
+          // descendant (CodeBlock pins its banner at z-index 6). The block is
+          // long enough that its banner has room to hold below the pinned
+          // header, which is where its Copy control must stay clickable; the
+          // list makes the body overflow the shrunk viewport.
+          + '```ts\nfunction resume(): boolean {\n'
+          + Array.from({ length: 26 }, (_, index) => `  const step${index + 1} = read(${index + 1})`).join('\n')
+          + '\n  return true\n}\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 },
@@ -462,6 +464,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
     const collapsedPosition = await marker.evaluate(element => getComputedStyle(element).position)
     expect(collapsedPosition).not.toBe('sticky')
     const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
+    // Captured so a failure in the cleanup below cannot replace the assertion
+    // that actually failed.
+    let bodyError: unknown
     try {
       await marker.click()
       await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
@@ -505,20 +510,20 @@ describe('web e2e: seeded history renders through cold resume', () => {
         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.
+      // Scroll so the summary's code banner reaches its own stuck position.
+      // The banner's sticky offset holds it below the pinned header's band, so
+      // the point this case samples is the banner's Copy control: the header
+      // must not cover it. 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 copy = banner.querySelector('button') as HTMLElement
+        // Both the toggle and the code banner are sticky, 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
@@ -532,15 +537,25 @@ describe('web e2e: seeded history renders through cold resume', () => {
         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)
+        // The banner sticks once its static top passes the band the toggle
+        // occupies. Land the static top 8px above the scrollport top: if the
+        // banner still pinned at top 0, 8px of it would sit under the toggle,
+        // so this position distinguishes the offset from the uncovered case.
+        // The banner must hold at the band's bottom edge, and its Copy control
+        // must stay the topmost element at its own center.
+        container.scrollTop = Math.max(0, bannerStaticTop + 8)
         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)
+        const copyRect = copy.getBoundingClientRect()
+        const currentContainerTop = container.getBoundingClientRect().top
+        const markerProbe = document.elementFromPoint(
+          markerRect.left + markerRect.width / 2,
+          markerRect.top + markerRect.height / 2,
+        )
+        const copyProbe = document.elementFromPoint(
+          copyRect.left + copyRect.width / 2,
+          copyRect.top + copyRect.height / 2,
+        )
         return {
           scrollTop: container.scrollTop,
           // The header's own content offset now lies above the scrollport top,
@@ -549,19 +564,21 @@ describe('web e2e: seeded history renders through cold resume', () => {
           // 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),
+          containerTop: currentContainerTop,
+          bannerTop: bannerRect.top,
+          bannerStuck: Math.abs(bannerRect.top - (currentContainerTop + headerHeight)) <= 1,
+          bannerBelowHeader: bannerRect.top >= markerRect.bottom - 1,
+          markerOwnsCenter: button.contains(markerProbe),
+          copyOwnsCenter: copy.contains(copyProbe),
         }
       })
       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.bannerStuck).toBe(true)
+      expect(geom.bannerBelowHeader).toBe(true)
       expect(geom.markerOwnsCenter).toBe(true)
+      expect(geom.copyOwnsCenter).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
@@ -581,29 +598,48 @@ describe('web e2e: seeded history renders through cold resume', () => {
         `- 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',
+        '## Scrolled so the summary code banner reaches its sticky offset',
         '',
         `- 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)}`,
+        `- summary code banner holds below the header band: ${String(geom.bannerStuck)}`,
+        `- summary code banner stays clear of the header: ${String(geom.bannerBelowHeader)}`,
+        `- banner Copy control owns its own center: ${String(geom.copyOwnsCenter)}`,
       ].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.
+    } catch (error) {
+      bodyError = error
+    }
+    // Restore the shared page state whether or not the body failed. Order
+    // matters: collapse the marker, restore the viewport, then re-enter
+    // follow-bottom. The control is what clears the off-floor ownership a
+    // programmatic `scrollTop` assignment leaves in ChatView's reader-movement
+    // ledger, so click it when it is there. It appears only after that ledger
+    // settles (`scrollend` or the sampling interval), and it never renders at
+    // all when the collapse's shrink clamp already re-entered follow, so the
+    // assertion is the restored state — no control, and the scrollport on its
+    // floor — rather than the control's presence.
+    try {
       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 scrollport = page.locator('[data-conversation-scroll]')
       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)
+      await expect.poll(async () => {
+        if (await backToBottom.count() > 0) await backToBottom.click()
+        const atFloor = await scrollport.evaluate((host: HTMLElement) =>
+          Math.abs(host.scrollHeight - host.clientHeight - host.scrollTop) <= 1)
+        return await backToBottom.count() === 0 && atFloor
+      }, { timeout: 15_000 }).toBe(true)
+    } catch (cleanupError) {
+      // The body's own assertion is the diagnosis; a cleanup failure would
+      // replace it, and the state it failed to restore shows up in the next
+      // case's golden.
+      if (bodyError === undefined) throw cleanupError
     }
+    if (bodyError !== undefined) throw bodyError
   })
 
   it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {

+ 22 - 4
packages/client/ui-chat/src/client/chat/MessageItem.module.css

@@ -53,6 +53,10 @@
    hover/focus disclosure for the summary body. Dimmed title (not
    label-primary) — the row is a boundary notice, not conversation content. */
 .compactionRow {
+  /* The toggle's line, and the band a pinned toggle occupies. The button's
+     height and the offset that holds a summary's code banner below that band
+     are the same measurement, so both read it from here. */
+  --dsh-compaction-header-height: calc(24px + var(--dsh-content-font-delta, 0px));
   padding: 2px 0;
 }
 
@@ -60,7 +64,7 @@
   display: flex;
   align-items: center;
   width: 100%;
-  height: calc(24px + var(--dsh-content-font-delta, 0px));
+  height: var(--dsh-compaction-header-height);
   min-width: 0;
   padding: 0;
   border: none;
@@ -75,16 +79,30 @@
    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. */
+   The square corners apply for as long as the row is open, pinned or not: the
+   fill is what masks the prose, and a 6px radius would leave that prose visible
+   in the corners. z-index outranks the markdown code block's own sticky banner
+   (z-index 6 in CodeBlock.module.css); that banner itself stops below this
+   toggle (rule below), so a summary's fenced code block never puts a control
+   inside the toggle's band. */
 .compactionRow:has(.compactionBody) .compactionButton {
   position: sticky;
   top: 0;
   z-index: 7;
+  border-radius: 0;
   background: var(--dsw-alias-bg-base);
 }
 
+/* A fenced code block in the summary pins its banner at the scrollport top,
+   where this pinned toggle already sits, and the toggle's band covers the
+   banner's Copy control. Stop the banner below the toggle instead, on the same
+   measurement the toggle's own height uses. This rule beats CodeBlock's
+   `.bannerWrap { top: 0 }` by specificity (0,2,0 against 0,1,0), not by
+   declaration order: the two sheets load in their own packages. */
+.compactionBody :has(> [data-code-block-banner]) {
+  top: var(--dsh-compaction-header-height);
+}
+
 /* 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. */

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

@@ -39,10 +39,29 @@ describe('pinned collapsible headers', () => {
       // 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',
+      // The opaque fill masks the prose only if the box has no rounded corners
+      // for it to show through.
+      'border-radius: 0',
       'background: var(--dsw-alias-bg-base)',
     ]))
   })
 
+  it('holds a summary code banner below the pinned header band, on the toggle\'s own measurement', () => {
+    const css = read('MessageItem.module.css')
+    // One home for the band height: the toggle's height and the offset that
+    // keeps a code banner clear of it must never drift apart, and sticky gives
+    // no error when they do.
+    expect(declarationsFrom(css, '.compactionRow')).toEqual(expect.arrayContaining([
+      '--dsh-compaction-header-height: calc(24px + var(--dsh-content-font-delta, 0px))',
+    ]))
+    expect(declarationsFrom(css, '.compactionButton')).toEqual(expect.arrayContaining([
+      'height: var(--dsh-compaction-header-height)',
+    ]))
+    expect(declarationsFrom(css, '.compactionBody :has(> [data-code-block-banner])')).toEqual(expect.arrayContaining([
+      'top: var(--dsh-compaction-header-height)',
+    ]))
+  })
+
   it('keeps the pinned compaction header opaque under hover', () => {
     expect(
       declarationsFrom(read('MessageItem.module.css'), '.compactionRow:has(.compactionBody) .compactionButton:hover'),

+ 4 - 2
snapshots/web/seeded-history/sticky-geometry.expected.md

@@ -11,10 +11,12 @@
 - header outranks the summary code-block banner: true
 - hover fill stays fully opaque: true
 
-## Scrolled so the code-block banner overlaps the header center
+## Scrolled so the summary code banner reaches its sticky offset
 
 - 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
+- summary code banner holds below the header band: true
+- summary code banner stays clear of the header: true
+- banner Copy control owns its own center: true