From 6d17de5da4ba7b779ef2f021cb2a0e29f22dbe84 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 3 Oct 2026 12:20:02 -0400 Subject: [PATCH] =?UTF-8?q?fix(chat):=20the=20composer=20stays=20put=20?= =?UTF-8?q?=E2=80=94=20the=20hidden=20speaker=20label=20was=20stretching?= =?UTF-8?q?=20the=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- docs/CHAT-TYPOGRAPHY.md | 21 +++++++++++ extensions/levelcode-ai/media/chat.html | 17 ++++++++- .../levelcode-ai/test/webviewCss.test.js | 36 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/CHAT-TYPOGRAPHY.md b/docs/CHAT-TYPOGRAPHY.md index aea80b8..81f62d3 100644 --- a/docs/CHAT-TYPOGRAPHY.md +++ b/docs/CHAT-TYPOGRAPHY.md @@ -180,6 +180,27 @@ transcript collapses into one undifferentiated column, the opposite of the inten height back in `em`, so it tracks D2's prose size. Net: **20.5px reclaimed per message** while the turn-boundary-to-continuation ratio *improves* from 1.5× to 2.2×. +**Corrected after shipping: the clipped label needs an anchor.** T4 measured the label's *box* and +never asked *where it was laid out*. The clipping pattern takes it out of flow with +`position: absolute`, and nothing above it was positioned — so every label was laid out against the +page rather than the transcript, at its unscrolled place in the log, out of reach of the log's +scrolling and clipping. A label that started more than a viewport down sat below the fold, the page +grew to reach that one pixel, and the page then scrolled: once the log was at its end, the wheel +moved the whole shell — transcript, composer and status row — up over blank space. It showed on any +conversation past its first long turn. + +`#log { position: relative }` makes the log the containing block for everything inside it: this +label, and anything out of flow added later. Measured the same way (Chromium, the shipped +`chat.html`, two turns, 1118px viewport): + +| | T4 as shipped | with the anchor | +| --- | --- | --- | +| label's `offsetParent` | `` | **`#log`** | +| the last label, its message scrolled to −22px | at 1962px: pinned to the page | **at −23px: 1px above its message** | +| page height | 1963px (that label, plus its pixel) | **1118px** (the viewport) | +| composer after 4 wheel ticks at the end of the log | 400px higher, blank page beneath | **unmoved** | +| transcript content height | 2919px | 2919px (nothing inside the log moves) | + ### D7 — It stays hackable: two settings, no hard-coded values. `levelcode.ai.chat.proseWidth` (px) and `levelcode.ai.chat.fontSize` (px). Both flow through the CSS diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 122716d..4a125c1 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -143,6 +143,17 @@ status row resolve the same values the transcript does. `--shell-x` is the log's horizontal padding AND the shell column's inset, which is what keeps the two edges on the same line. */ #log { flex: 1; overflow-y: auto; padding: 12px var(--shell-x); display: flex; flex-direction: column; gap: 12px; } + /* THE LOG CONTAINS WHAT IS IN IT. The page is a 100vh shell and this is the only thing in it that + scrolls, so what sits below it — the composer, the status row — never moves. That holds only while + nothing in the transcript is laid out against the PAGE, and an out-of-flow box with no positioned + ancestor is: it sits at its unscrolled place in the transcript, where neither this element's + scrolling nor its clipping reaches it. `.msg .role` (below) is such a box. Every labelled message + that starts more than a viewport down the transcript — in practice, every turn after a long first + one — put its label below the fold; the page grew to reach that one pixel, and the wheel, once the + log was at its end, scrolled the whole shell away: transcript, composer and status row riding up + over a blank page. Positioned, the log is the containing block for every descendant: the label, + and whatever is added later. webviewCss.test.js fails if this goes. */ + #log { position: relative; } /* Log is a flex column → children default to flex-shrink:1. Any child with overflow:hidden (e.g. the approval/edit cards) then gets an auto min-size of 0 and the flex algorithm crushes it to an invisible line once the log overflows. Pin every child to its natural height; the log itself scrolls instead. */ @@ -241,7 +252,11 @@ wall of text. So the element stays in the accessibility tree and leaves the visual layer. This is why the rule below must never be "simplified" to `display: none` or `visibility: hidden` — both take it out of the a11y tree too, which is the whole thing we are preserving. webviewCss.test.js - fails on either. */ + fails on either. + + ABSOLUTE NEEDS AN ANCHOR. Taking the label out of flow is what stops it reserving a line — and it + also means the label is laid out against its nearest positioned ancestor. That is `#log` (see + `#log { position: relative }` above). Without one it is the page, and the page scrolls. */ .msg .role { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js index 5c4056a..4a1eef6 100644 --- a/extensions/levelcode-ai/test/webviewCss.test.js +++ b/extensions/levelcode-ai/test/webviewCss.test.js @@ -516,6 +516,42 @@ test('TRANSCRIPT: the speaker label leaves the screen but NOT the accessibility assert.match(add, /cont \? '' : '
/, 'a continuation must still omit the label element entirely'); }); +test('SHELL: the transcript is its own containing block — nothing in it is laid out against the page', () => { + // The page is a 100vh flex column and #log is the only thing in it that scrolls: the composer and + // the status row sit under it and never move. A box taken out of flow (position: absolute) is laid + // out against its nearest POSITIONED ancestor — and with none, against the page, at its unscrolled + // place in the transcript, where neither #log's scrolling nor its clipping reaches it. + // + // The speaker label is such a box; the test above requires it to be. With nothing positioned above + // it, the label of every message that started more than a viewport down the transcript lay below + // the fold. The page grew to reach that one pixel, and once the log was at its end the wheel + // scrolled the PAGE: transcript, composer and status row riding up together over blank space, on + // every conversation past its first long turn. Measured in Chromium against the shipped file, two + // turns in a 1118px viewport: the page was 1963px tall, the labels' offsetParent was , and + // four wheel ticks moved the composer up by 400px. Positioned: 1118px, #log, 0px. + // + // No DOM test can see any of that — the fake DOM lays nothing out — so the rule is pinned here. + const logRules = [...cssBlocks.matchAll(/(?:^|[}\n])\s*#log\s*\{([^}]*)\}/g)].map((m) => m[1]); + assert.ok(logRules.length, '#log no longer has a rule of its own'); + + // The same selector each time, so the last declaration is the one in force. + const declared = logRules.map((b) => (/(?:^|[;\s])position\s*:\s*([a-z-]+)/.exec(b) || [])[1]).filter(Boolean); + const position = declared[declared.length - 1]; + assert.ok(position && position !== 'static', + '#log must be positioned (position: relative). It is the containing block for the clipped speaker\n' + + 'label; without it every label is laid out against the page, the page grows past the viewport, and\n' + + 'the whole chat — composer included — scrolls away. Got: ' + (position || 'no position at all')); + + // The premise. If the shell is ever rebuilt, this is the test to rethink rather than delete. + const shell = blockAt(cssBlocks, cssBlocks.search(/(?:^|\n)\s*body\s*\{/)); + assert.match(shell, /(?:^|[;\s])height:\s*100vh/, 'premise: the page is exactly one viewport tall (height, not min-height)'); + assert.ok(logRules.some((b) => /overflow-y\s*:\s*auto/.test(b)), 'premise: #log is the element that scrolls'); + + // And the label is still what needs it: out of flow, with no offsets of its own to pin it anywhere. + const label = /\.msg \.role \{([^}]*)\}/.exec(cssBlocks); + assert.ok(label && /position:\s*absolute/.test(label[1]), 'the label is no longer out of flow — see the test above'); +}); + test('TRANSCRIPT: dropping the label does not collapse the gap between speakers', () => { // The label was doing spacing work nobody had accounted for: ~19px above every turn. Remove it and // a new turn is separated from a continuation by 12px versus 7px — not a difference you can see, so