Skip to content

Commit 6d17de5

Browse files
committed
fix(chat): the composer stays put — the hidden speaker label was stretching the page
Past a conversation's first long turn, scrolling beyond the end of the transcript moved the whole chat: transcript, composer and status row slid up together over a blank page. The page is a 100vh shell and only #log is meant to scroll, so the composer should never move at all. The page itself had become scrollable. T4 (59777da) takes the speaker label — "You" / "LevelCode AI" — off the screen and keeps it for screen readers, with the usual rule for that: position: absolute, 1px, clipped. Nothing above the label was positioned, so its containing block was the page, not the transcript. Each label was laid out at its UNSCROLLED place in the log, where the log's scrolling and clipping do not reach it. A labelled message that started more than a viewport down had its label below the fold; the page grew to reach that one pixel; and once the log was at its end, the wheel scrolled the page. In practice: from the second turn of any conversation whose first one was long. #log is now positioned (position: relative), which makes it the containing block for everything inside it — this label, and anything taken out of flow later. The label is as it was: out of flow, clipped, and in the accessibility tree. Not done: locking the page with overflow: hidden. It would hide the symptom and leave the cause. And in a window too short for the composer and the status row — the shell needs 208px, measured in a 140px viewport — scrolling the page is the only way to reach the bottom controls. Verified in Chromium against the shipped chat.html, served the way the host serves it (CSP and nonce filled in, acquireVsCodeApi stubbed) and driven with the host's own messages: two agent turns, the first with a long code block, in a 1118px viewport. before after label's offsetParent <body> #log last label, its message at -22px 1962px -23px page height 1963px 1118px composer after 4 wheel ticks -400px unmoved transcript content height 2919px 2919px Scrolling up inside the transcript still pauses auto-scroll and shows "jump to latest". No console errors. webviewCss.test.js gains the guard no DOM test can give, since the fake DOM lays nothing out: #log is positioned and stays positioned, with the shell's premise pinned beside it. It fails on develop's chat.html, on position: static, and on a later rule that un-positions the log. 48 suites, 925 cases, 0 failing (scripts/test-extensions.sh). docs/CHAT-TYPOGRAPHY.md D6 records the correction and the measurements. Not run in the editor: this worktree has no vscode/ build. Not reproduced: a single run that works first and answers last. Tool activity folds into a compact group, so that transcript stays short; the trigger confirmed here is a later turn that starts below the fold.
1 parent f7f6843 commit 6d17de5

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

‎docs/CHAT-TYPOGRAPHY.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,27 @@ transcript collapses into one undifferentiated column, the opposite of the inten
180180
height back in `em`, so it tracks D2's prose size. Net: **20.5px reclaimed per message** while the
181181
turn-boundary-to-continuation ratio *improves* from 1.5× to 2.2×.
182182

183+
**Corrected after shipping: the clipped label needs an anchor.** T4 measured the label's *box* and
184+
never asked *where it was laid out*. The clipping pattern takes it out of flow with
185+
`position: absolute`, and nothing above it was positioned — so every label was laid out against the
186+
page rather than the transcript, at its unscrolled place in the log, out of reach of the log's
187+
scrolling and clipping. A label that started more than a viewport down sat below the fold, the page
188+
grew to reach that one pixel, and the page then scrolled: once the log was at its end, the wheel
189+
moved the whole shell — transcript, composer and status row — up over blank space. It showed on any
190+
conversation past its first long turn.
191+
192+
`#log { position: relative }` makes the log the containing block for everything inside it: this
193+
label, and anything out of flow added later. Measured the same way (Chromium, the shipped
194+
`chat.html`, two turns, 1118px viewport):
195+
196+
| | T4 as shipped | with the anchor |
197+
| --- | --- | --- |
198+
| label's `offsetParent` | `<body>` | **`#log`** |
199+
| the last label, its message scrolled to −22px | at 1962px: pinned to the page | **at −23px: 1px above its message** |
200+
| page height | 1963px (that label, plus its pixel) | **1118px** (the viewport) |
201+
| composer after 4 wheel ticks at the end of the log | 400px higher, blank page beneath | **unmoved** |
202+
| transcript content height | 2919px | 2919px (nothing inside the log moves) |
203+
183204
### D7 — It stays hackable: two settings, no hard-coded values.
184205

185206
`levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@
143143
status row resolve the same values the transcript does. `--shell-x` is the log's horizontal padding
144144
AND the shell column's inset, which is what keeps the two edges on the same line. */
145145
#log { flex: 1; overflow-y: auto; padding: 12px var(--shell-x); display: flex; flex-direction: column; gap: 12px; }
146+
/* THE LOG CONTAINS WHAT IS IN IT. The page is a 100vh shell and this is the only thing in it that
147+
scrolls, so what sits below it — the composer, the status row — never moves. That holds only while
148+
nothing in the transcript is laid out against the PAGE, and an out-of-flow box with no positioned
149+
ancestor is: it sits at its unscrolled place in the transcript, where neither this element's
150+
scrolling nor its clipping reaches it. `.msg .role` (below) is such a box. Every labelled message
151+
that starts more than a viewport down the transcript — in practice, every turn after a long first
152+
one — put its label below the fold; the page grew to reach that one pixel, and the wheel, once the
153+
log was at its end, scrolled the whole shell away: transcript, composer and status row riding up
154+
over a blank page. Positioned, the log is the containing block for every descendant: the label,
155+
and whatever is added later. webviewCss.test.js fails if this goes. */
156+
#log { position: relative; }
146157
/* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the
147158
approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible
148159
line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */
@@ -241,7 +252,11 @@
241252
wall of text. So the element stays in the accessibility tree and leaves the visual layer. This is
242253
why the rule below must never be "simplified" to `display: none` or `visibility: hidden` — both
243254
take it out of the a11y tree too, which is the whole thing we are preserving. webviewCss.test.js
244-
fails on either. */
255+
fails on either.
256+
257+
ABSOLUTE NEEDS AN ANCHOR. Taking the label out of flow is what stops it reserving a line — and it
258+
also means the label is laid out against its nearest positioned ancestor. That is `#log` (see
259+
`#log { position: relative }` above). Without one it is the page, and the page scrolls. */
245260
.msg .role {
246261
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
247262
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;

‎extensions/levelcode-ai/test/webviewCss.test.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,42 @@ test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility
516516
assert.match(add, /cont \? '' : '<div class="role">/, 'a continuation must still omit the label element entirely');
517517
});
518518

519+
test('SHELL: the transcript is its own containing block — nothing in it is laid out against the page', () => {
520+
// The page is a 100vh flex column and #log is the only thing in it that scrolls: the composer and
521+
// the status row sit under it and never move. A box taken out of flow (position: absolute) is laid
522+
// out against its nearest POSITIONED ancestor — and with none, against the page, at its unscrolled
523+
// place in the transcript, where neither #log's scrolling nor its clipping reaches it.
524+
//
525+
// The speaker label is such a box; the test above requires it to be. With nothing positioned above
526+
// it, the label of every message that started more than a viewport down the transcript lay below
527+
// the fold. The page grew to reach that one pixel, and once the log was at its end the wheel
528+
// scrolled the PAGE: transcript, composer and status row riding up together over blank space, on
529+
// every conversation past its first long turn. Measured in Chromium against the shipped file, two
530+
// turns in a 1118px viewport: the page was 1963px tall, the labels' offsetParent was <body>, and
531+
// four wheel ticks moved the composer up by 400px. Positioned: 1118px, #log, 0px.
532+
//
533+
// No DOM test can see any of that — the fake DOM lays nothing out — so the rule is pinned here.
534+
const logRules = [...cssBlocks.matchAll(/(?:^|[}\n])\s*#log\s*\{([^}]*)\}/g)].map((m) => m[1]);
535+
assert.ok(logRules.length, '#log no longer has a rule of its own');
536+
537+
// The same selector each time, so the last declaration is the one in force.
538+
const declared = logRules.map((b) => (/(?:^|[;\s])position\s*:\s*([a-z-]+)/.exec(b) || [])[1]).filter(Boolean);
539+
const position = declared[declared.length - 1];
540+
assert.ok(position && position !== 'static',
541+
'#log must be positioned (position: relative). It is the containing block for the clipped speaker\n'
542+
+ 'label; without it every label is laid out against the page, the page grows past the viewport, and\n'
543+
+ 'the whole chat — composer included — scrolls away. Got: ' + (position || 'no position at all'));
544+
545+
// The premise. If the shell is ever rebuilt, this is the test to rethink rather than delete.
546+
const shell = blockAt(cssBlocks, cssBlocks.search(/(?:^|\n)\s*body\s*\{/));
547+
assert.match(shell, /(?:^|[;\s])height:\s*100vh/, 'premise: the page is exactly one viewport tall (height, not min-height)');
548+
assert.ok(logRules.some((b) => /overflow-y\s*:\s*auto/.test(b)), 'premise: #log is the element that scrolls');
549+
550+
// And the label is still what needs it: out of flow, with no offsets of its own to pin it anywhere.
551+
const label = /\.msg \.role \{([^}]*)\}/.exec(cssBlocks);
552+
assert.ok(label && /position:\s*absolute/.test(label[1]), 'the label is no longer out of flow — see the test above');
553+
});
554+
519555
test('TRANSCRIPT: dropping the label does not collapse the gap between speakers', () => {
520556
// The label was doing spacing work nobody had accounted for: ~19px above every turn. Remove it and
521557
// a new turn is separated from a continuation by 12px versus 7px — not a difference you can see, so

0 commit comments

Comments
 (0)