Files
deepseek-harness/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md

7.1 KiB

Agent Note: The conversation column reserves one scrollbar gutter for every view

Status: implemented

English | 中文

Problem

The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown.

In Chat it is a sticky CHILD of the column's scroller ([data-conversation-scroll]), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares data-conversation-composer-overlay, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller overflow: hidden and positioned the seat absolutely, against the padding box, which no scrollbar reduces.

So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn.

Decision

.scrollBody declares scrollbar-gutter: stable unconditionally, and the overlay branch declares the same box a scroll container on both axes — overflow-x: hidden; overflow-y: auto — instead of overflow: hidden.

The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. stable rather than auto because auto reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an auto gutter would state the bug rather than fix it.

The overlay state is a scroll container that nothing scrolls: the view fills it (flex: 1 1 0 with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies scrollbar-gutter to an overflow-y: auto box and ignores it on a hidden one — measured on this app's own composer layers and recorded in the composer scrollport note — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.

The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes visible on the other to auto, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.

The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: ::-webkit-scrollbar carries a width in ui-theme's sheet (themed scrollbars), and the sidebar's session list already reserves its own gutter for the same reason.

Alternatives considered

Inset the overlay seat by the bar's width. The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever scrollbar-width: thin resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is.

Keep overflow: hidden and add scrollbar-gutter: stable alone. The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent.

Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry. This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript (sticky composer), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one.

Pad the column by the bar's width instead of reserving a gutter. Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it.

Consequences

  • Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
  • The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
  • The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
  • The committed golden records the reserved band, so a change to the sheet's ::-webkit-scrollbar width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.

Testing

apps/web/tests/composer-tab-geometry.e2e.ts measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.

The scenario launches chromium without Playwright's default --hide-scrollbars, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.

The pre-fix cascade is then applied in the page — scrollbar-gutter: auto on the scroller, overflow: hidden on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.