From c335b02361a3a9e8c061872e112bb87ea041305e Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:32:22 +0000 Subject: [PATCH] Reserve reply room for the visible latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Möbius Agent --- ARCHITECTURE.md | 50 +-- .../__tests__/anchorHeldThroughToggle.test.js | 45 ++- .../ChatView/__tests__/chatContract.test.js | 13 +- .../__tests__/preserveTogglePosition.test.js | 130 +------- .../__tests__/scrollOwnership.test.js | 27 +- .../ChatView/__tests__/useScrollMode.test.js | 286 ++++++++++++++---- .../src/components/ChatView/chatContract.js | 32 +- .../ChatView/preserveTogglePosition.js | 45 --- .../src/components/ChatView/useScrollMode.js | 197 +++++++----- 9 files changed, 439 insertions(+), 386 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 70700e43d..18463d32b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -503,7 +503,7 @@ automatically armed by installing Möbius. ## Chat scroll + steer contract -**Owner-authoritative contract — v1.6 (2026-07-22).** This section is the +**Owner-authoritative contract — v1.7 (2026-07-24).** This section is the canonical source of truth for how a chat scrolls and steers. When implementation, comments, and this contract disagree, the implementation/comments are the bug: fix behavior to match this contract. If a real case is unspecified or the desired @@ -527,12 +527,19 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec waits for the spacer-exhaustion handoff. A viewport / keyboard change, foreground return, mount, or chat restoration must never create auto-scroll. -- **R1 — Permanent exact reservation.** Every non-empty chat keeps enough dynamic - bottom spacer for its latest visible user message to reach the viewport top, - including after leaving and reopening the chat. The reservation is exact — no - extra scrollable blank beyond that target — and shrinks as the reply fills it. - `FOLLOW_BOTTOM` follows real conversation content, excluding the reservation, so - a short restored chat cannot open on an empty viewport. +- **R1 — Visible latest-user reservation.** Dynamic bottom spacer belongs only to + the latest visible user row. It reserves exactly enough room for that row to reach + the viewport top and shrinks as reply, tool, image, or other content fills the + deficit. The remaining room survives turn completion when the reply is short. + Expanding content consumes it; collapsing the same content restores the exact + deficit. Mode does not own its lifetime: `PIN_USER_MSG` may reserve before a fresh + row is placed, while `ANCHOR_AT`, `FOLLOW_BOTTOM`, mount/return, question + submission, and disclosure settlement reserve only when their real viewport + contains the latest user row. If that row leaves the viewport, spacer is zero; + seeing an older user row never qualifies. An otherwise unreachable anchor clamps + to real conversation content before visibility is decided. Positive dynamic + spacer therefore always implies that the latest user row is visible at the + current/applied target. - **R2 — One send rule everywhere.** The first visible user message always pins to the viewport top. Every subsequent direct, queued, promoted, or steered message pins when its submit-time DOM snapshot is at the real-content tail. Geometry is @@ -569,6 +576,9 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec manufacture a top-of-chat location or engage live following. That automatic tail fallback is not a reader-chosen location and must not be persisted on pagehide or shell reload; only a deliberate scroll/send/pagination position earns restoration. + Exactness is bounded by real content: if a viewport growth or content collapse + makes the saved target unreachable, clamp it to the nearest real conversation + position, then apply R1 only if that viewport shows the latest user row. - **R5 — Reader owns gestures and layout-only sends.** From the first wheel/touch/key input until its scroll event lands, no layout path may write `scrollTop`: stream resize, spacer handoff, terminal promotion, catch-up, and viewport/keyboard resize @@ -608,11 +618,11 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec card enters its pending state or output resumes, the controller snapshots the currently visible message and its exact viewport offset as `ANCHOR_AT`. Resumed output grows without dragging the reader, even when the chat had been following - the tail before Submit. If a mobile viewport grows before that output arrives, - the dynamic spacer temporarily reserves enough room to keep the anchor target - reachable; the reservation disappears as real content replaces it. A failed - answer keeps that settled reading anchor for the retryable card rather than - manufacturing follow intent again. + the tail before Submit. The answer anchor does not independently own spacer; it + receives R1's exact deficit only while its viewport still shows the latest user + row. If a viewport growth makes its exact offset unreachable, it clamps to real + conversation content. A failed answer keeps that settled reading anchor for the + retryable card rather than manufacturing follow intent again. The source handoff preserves the question, its answer, and every pre/post-answer thinking, tool, and text block in event order, without hiding, duplicating, or reordering them. Only a @@ -633,14 +643,15 @@ path means routing it through the same entries rather than inventing another rul | First direct/queued/steered user row becomes visible | any | `PIN_USER_MSG` | New row to top | | Later send submitted at real-content tail (mode may be one frame stale) | any | `PIN_USER_MSG` | New row to top | | Later send submitted anywhere else | hold or stale follow | `ANCHOR_AT`/existing hold | None | -| Reader reaches physical bottom while live reservation remains | any | armed `PIN_USER_MSG` | User-owned; then keep prompt fixed | -| Reader reaches physical bottom while idle reservation remains | any | settled `PIN_USER_MSG` | User-owned; keep prompt fixed | +| Reader reaches physical bottom while latest-user reservation remains and turn is live | any | armed `PIN_USER_MSG` | User-owned; then keep prompt fixed | +| Reader reaches physical bottom while latest-user reservation remains and turn is idle | any | settled `PIN_USER_MSG` | User-owned; keep prompt fixed | | Reader reaches bottom with no reservation remaining | any | `FOLLOW_BOTTOM` | User-owned | | Reader scrolls manually away from bottom | any | `ANCHOR_AT` | User-owned | | Reply grows while an armed live pin still has reserved room | pin hold | same pin hold | Keep prompt fixed | | Streaming reply consumes the armed pin reservation | pin hold | `FOLLOW_BOTTOM` | Follow real-content tail | | Short reply settles before consuming the reservation | armed pin hold | settled pin hold | Keep prompt fixed; retire automatic handoff | -| Other layout grows while pinned or anchored | hold | same hold | Reapply only the held target | +| Other layout grows/collapses while latest user is visible | any hold | same hold | Consume/restore exact R1 deficit | +| Latest user leaves the viewport | any | same reader mode | Collapse spacer to zero | | Viewport/keyboard changes | `PIN_USER_MSG` | same `PIN_USER_MSG` | Reapply pin after resize; never infer intent from keyboard-open geometry | | Viewport/keyboard changes | follow or anchor hold | same follow if still at tail, otherwise hold anchor | Never creates follow | | Chat exits/backgrounds/returns | any | `ANCHOR_AT` | Restore exact saved anchor | @@ -656,6 +667,10 @@ Controller structure is part of the contract, not an implementation detail: - Every live mode mutation goes through `transitionMode`; every mode-owned `scrollTop` write goes through `writeMode`. The exported `applyMode` executor is for the controller and pure unit tests, not a second live writer. +- `useScrollMode` is the sole writer of `.spacer-dynamic` height. The write is + derived from the latest user row's visibility and exact content deficit; + disclosure helpers and renderers may preserve an on-screen anchor but may never + prime, enlarge, or unwind spacer themselves. - The gesture-gated `scroll` event reads physical-bottom geometry directly. Do not reintroduce a sentinel or asynchronous observer as a second bottom authority: its delayed state can contradict the viewport that caused the @@ -679,8 +694,9 @@ but never backward. Do not derive a remounted timer solely from `Date.now()` or client arrival time of replayed deltas: catch-up arrives as a burst and that makes a minutes-old turn visibly restart at one second. -Every visible user row also makes R1's reservation current, whether or not that row -pins. Reservation lifetime and pin decisions are independent. +Only the latest visible user row makes R1's reservation current. Reservation +lifetime follows that row's visibility and exact remaining deficit, not turn +completion or a particular scroll mode. - **A restored send is one logical message.** The frontend scopes the draft identity to the chat and reuses its client-minted `cid` when an ambiguous failed POST restores an unchanged composer. The route checks that durable diff --git a/frontend/src/components/ChatView/__tests__/anchorHeldThroughToggle.test.js b/frontend/src/components/ChatView/__tests__/anchorHeldThroughToggle.test.js index 5e72cd766..825030b24 100644 --- a/frontend/src/components/ChatView/__tests__/anchorHeldThroughToggle.test.js +++ b/frontend/src/components/ChatView/__tests__/anchorHeldThroughToggle.test.js @@ -4,41 +4,37 @@ import assert from 'node:assert/strict' import { anchorHeldThroughToggle } from '../chatContract.js' // The disclosure seam (thinking/tool/activity collapse) is where the chat UI -// regressed repeatedly: a bounce (anchor top moved across the toggle) or spacer -// over-reservation (rapid toggles stacked body heights). This predicate is the -// machine-checkable law for that interaction; these tests pin its behavior so a -// browser monitor or Playwright spec can trust it. +// regressed repeatedly: a bounce (anchor top moved across the toggle) or blank +// room without the latest user row. This predicate is the machine-checkable +// law for an ANCHOR_AT toggle. test('a held anchor with no spacer growth passes', () => { const r = anchorHeldThroughToggle( - { anchorTop: 120, spacerH: 200 }, - { anchorTop: 121, spacerH: 200 }, - { bodyH: 0 }) + { anchorTop: 120, spacerH: 0 }, + { anchorTop: 121, spacerH: 0 }) assert.equal(r.ok, true) }) test('an anchor that bounced past tolerance fails', () => { const r = anchorHeldThroughToggle( - { anchorTop: 120, spacerH: 200 }, - { anchorTop: 140, spacerH: 200 }) + { anchorTop: 120, spacerH: 0 }, + { anchorTop: 140, spacerH: 0 }) assert.equal(r.ok, false) assert.equal(r.measured.drift, 20) }) -test('a collapse may grow the spacer by exactly one body height', () => { - const held = anchorHeldThroughToggle( - { anchorTop: 500, spacerH: 100 }, - { anchorTop: 500, spacerH: 148 }, - { bodyH: 48 }) - assert.equal(held.ok, true) +test('an anchored disclosure cannot leave room without the latest user visible', () => { + const reserved = anchorHeldThroughToggle( + { anchorTop: 500, spacerH: 0 }, + { anchorTop: 500, spacerH: 48 }) + assert.equal(reserved.ok, false) }) -test('a spacer that stacked two body heights (rapid-toggle regression) fails', () => { - const stacked = anchorHeldThroughToggle( - { anchorTop: 500, spacerH: 100 }, - { anchorTop: 500, spacerH: 196 }, // baseline 100 + 48 + 48 - { bodyH: 48 }) - assert.equal(stacked.ok, false) +test('collapse may restore reservation when the latest user remains visible', () => { + const reserved = anchorHeldThroughToggle( + { anchorTop: 500, spacerH: 0 }, + { anchorTop: 500, spacerH: 48, latestUserVisible: true }) + assert.equal(reserved.ok, true) }) test('missing anchor data is indeterminate, never a false pass', () => { @@ -47,12 +43,9 @@ test('missing anchor data is indeterminate, never a false pass', () => { assert.match(r.reason, /anchorTop/) }) -test('an explicit spacerBaseline overrides before.spacerH', () => { - // A caller that primed the spacer before measuring `before` passes the true - // pre-toggle baseline so the accumulation check is against the right value. +test('a stale pre-toggle spacer passes only when the toggle leaves none behind', () => { const r = anchorHeldThroughToggle( - { anchorTop: 300, spacerH: 250 }, // already primed { anchorTop: 300, spacerH: 250 }, - { bodyH: 48, spacerBaseline: 202 }) + { anchorTop: 300, spacerH: 0 }) assert.equal(r.ok, true) }) diff --git a/frontend/src/components/ChatView/__tests__/chatContract.test.js b/frontend/src/components/ChatView/__tests__/chatContract.test.js index f93b27cea..d7ef0c9ca 100644 --- a/frontend/src/components/ChatView/__tests__/chatContract.test.js +++ b/frontend/src/components/ChatView/__tests__/chatContract.test.js @@ -67,17 +67,22 @@ test('ChatView only consumes methods returned by the scroll controller', () => { `ChatView consumes missing useScrollMode members: ${missing.join(', ')}`) }) -test('owner contract freezes question answers without changing active-row ownership', () => { +test('owner contract freezes question answers without changing row or spacer ownership', () => { const architecture = readFileSync( new URL('../../../../../ARCHITECTURE.md', import.meta.url), 'utf8', ) - assert.match(architecture, /Owner-authoritative contract — v1\.6 \(2026-07-22\)/) + assert.match(architecture, /Owner-authoritative contract — v1\.7 \(2026-07-24\)/) assert.match( architecture, /In-process question is answered \| any \| `ANCHOR_AT` on current visible row; same active assistant row/, 'question submission must freeze the reader while preserving the R6 row', ) + assert.match( + architecture, + /The answer anchor does not independently own spacer/, + 'question submission must use the latest-user visibility rule', + ) }) test('a retained chat crosses the old unmount lifecycle while hidden', () => { @@ -389,8 +394,8 @@ test('every predicate id is registered in CHAT_CONTRACT (registry is the map)', cushionPresent(snapshotChatUX(pinnedEnv)), singleAssistantSurface(1), anchorHeldThroughToggle( - { anchorTop: 100, spacerH: 40 }, - { anchorTop: 100, spacerH: 40 }, + { anchorTop: 100, spacerH: 0 }, + { anchorTop: 100, spacerH: 0 }, ), ] for (const r of emitted) { diff --git a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js index ab1ba3ea3..aa6284424 100644 --- a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js +++ b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js @@ -124,19 +124,20 @@ test('FOLLOW_BOTTOM leaves toggle movement entirely to the scroll controller', ( } }) -test('closing a disclosure primes the bottom spacer before React removes its body', () => { +test('disclosure preservation never reads or writes the dynamic spacer', () => { const originalMutationObserver = globalThis.MutationObserver const originalRaf = globalThis.requestAnimationFrame - const originalGetComputedStyle = globalThis.getComputedStyle globalThis.MutationObserver = undefined globalThis.requestAnimationFrame = () => 1 - globalThis.getComputedStyle = () => ({ marginTop: '4px', marginBottom: '2px' }) try { - const spacer = { offsetHeight: 100, style: {} } + let queried = false const scroller = { scrollTop: 50, - querySelector: selector => selector === '.spacer-dynamic' ? spacer : null, + querySelector: () => { + queried = true + throw new Error('disclosures do not own reservation geometry') + }, } const body = { getBoundingClientRect: () => ({ height: 60 }) } const anchor = { @@ -148,127 +149,10 @@ test('closing a disclosure primes the bottom spacer before React removes its bod } preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '166px') + assert.equal(queried, false) } finally { globalThis.MutationObserver = originalMutationObserver globalThis.requestAnimationFrame = originalRaf - globalThis.getComputedStyle = originalGetComputedStyle - } -}) - -test('opening a disclosure leaves normal spacer sizing alone', () => { - const originalMutationObserver = globalThis.MutationObserver - const originalRaf = globalThis.requestAnimationFrame - globalThis.MutationObserver = undefined - globalThis.requestAnimationFrame = () => 1 - - try { - const spacer = { offsetHeight: 100, style: {} } - const scroller = { - scrollTop: 50, - querySelector: () => spacer, - } - const anchor = { - parentElement: {}, - nextElementSibling: null, - closest: () => scroller, - getAttribute: () => 'false', - getBoundingClientRect: () => ({ top: 120 }), - } - - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, undefined) - } - finally { - globalThis.MutationObserver = originalMutationObserver - globalThis.requestAnimationFrame = originalRaf - } -}) - -test('a fast close-open-close cycle unwinds provisional space instead of accumulating it', () => { - const originalMutationObserver = globalThis.MutationObserver - const originalRaf = globalThis.requestAnimationFrame - const originalGetComputedStyle = globalThis.getComputedStyle - globalThis.MutationObserver = undefined - globalThis.requestAnimationFrame = () => 1 - globalThis.getComputedStyle = () => ({ marginTop: '4px', marginBottom: '2px' }) - - try { - const spacer = { - style: { height: '100px' }, - get offsetHeight() { return Number.parseFloat(this.style.height) }, - } - const scroller = { - scrollTop: 50, - querySelector: () => spacer, - } - const body = { getBoundingClientRect: () => ({ height: 60 }) } - let expanded = 'true' - const anchor = { - parentElement: {}, - nextElementSibling: body, - closest: () => scroller, - getAttribute: () => expanded, - getBoundingClientRect: () => ({ top: 120 }), - } - - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '166px') - - // Re-open before ResizeObserver has replaced the provisional value. - expanded = 'false' - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '100px') - - // A second fast close reserves one body height, not two. - expanded = 'true' - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '166px') - } - finally { - globalThis.MutationObserver = originalMutationObserver - globalThis.requestAnimationFrame = originalRaf - globalThis.getComputedStyle = originalGetComputedStyle - } -}) - -test('provisional cleanup never overwrites a newer authoritative spacer value', () => { - const originalMutationObserver = globalThis.MutationObserver - const originalRaf = globalThis.requestAnimationFrame - const originalGetComputedStyle = globalThis.getComputedStyle - globalThis.MutationObserver = undefined - globalThis.requestAnimationFrame = () => 1 - globalThis.getComputedStyle = () => ({ marginTop: '0px', marginBottom: '0px' }) - - try { - const spacer = { - style: { height: '100px' }, - get offsetHeight() { return Number.parseFloat(this.style.height) }, - } - const scroller = { scrollTop: 0, querySelector: () => spacer } - const body = { getBoundingClientRect: () => ({ height: 60 }) } - let expanded = 'true' - const anchor = { - parentElement: {}, - nextElementSibling: body, - closest: () => scroller, - getAttribute: () => expanded, - getBoundingClientRect: () => ({ top: 80 }), - } - - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '160px') - - // Simulate ResizeObserver publishing new settled geometry. - spacer.style.height = '112px' - expanded = 'false' - preserveTogglePosition(anchor) - assert.equal(spacer.style.height, '112px') - } - finally { - globalThis.MutationObserver = originalMutationObserver - globalThis.requestAnimationFrame = originalRaf - globalThis.getComputedStyle = originalGetComputedStyle } }) diff --git a/frontend/src/components/ChatView/__tests__/scrollOwnership.test.js b/frontend/src/components/ChatView/__tests__/scrollOwnership.test.js index 312b49013..2ad1e631a 100644 --- a/frontend/src/components/ChatView/__tests__/scrollOwnership.test.js +++ b/frontend/src/components/ChatView/__tests__/scrollOwnership.test.js @@ -4,21 +4,16 @@ import { dirname, join } from 'node:path' import { test } from 'node:test' import assert from 'node:assert/strict' -// Sole-ownership guard for the dynamic bottom spacer — the resource the chat -// scroll contract fights over. useScrollMode.js's sizeSpacer and -// preserveTogglePosition.js's collapse-prime are the ONLY sanctioned writers of -// `.spacer-dynamic` height; they coordinate the pin/anchor geometry. A third -// component writing that height directly is exactly how the collapse-bounce and -// spacer-over-reservation regressions entered — it mutates the contested -// quantity without going through (or informing) the mode machine. This -// source-scan makes a new writer trip a red test instead of shipping a scroll -// bug. (When preserveTogglePosition is folded into useScrollMode — ARCHITECTURE -// "one owner" hardening — drop it from ALLOWED and this proves single ownership.) +// Sole-writer guard for the dynamic bottom spacer. useScrollMode derives its +// exact height from the latest user row's visibility and content deficit. A +// disclosure, renderer, or component writing the same height independently can +// strand provisional blank room after QA/tool/image layout changes, so any +// second writer is a contract bug. const dir = dirname(fileURLToPath(import.meta.url)) const chatViewDir = join(dir, '..') -const ALLOWED = new Set(['useScrollMode.js', 'preserveTogglePosition.js']) +const OWNER = 'useScrollMode.js' // A line that assigns a height to the dynamic spacer. Matches // `spacer.style.height = ...` / `spacerEl.style.height = ...` on any variable, @@ -40,23 +35,23 @@ function sourceFiles(root) { test('only sanctioned modules write the dynamic spacer height', () => { const offenders = [] for (const { name, full } of sourceFiles(chatViewDir)) { - if (ALLOWED.has(name)) continue + if (name === OWNER) continue const src = readFileSync(full, 'utf8') if (!src.includes('.spacer-dynamic')) continue if (SPACER_HEIGHT_WRITE.test(src)) offenders.push(name) } assert.deepEqual(offenders, [], - `these modules write .spacer-dynamic height outside the sanctioned owners ` - + `(${[...ALLOWED].join(', ')}): ${offenders.join(', ')}. Route spacer sizing ` + `these modules write .spacer-dynamic height outside its sole owner ` + + `(${OWNER}): ${offenders.join(', ')}. Route spacer sizing ` + `through useScrollMode's sizeSpacer instead of mutating it directly.`) }) -test('the two sanctioned spacer owners still exist (guard is not vacuous)', () => { +test('the sole spacer owner still exists (guard is not vacuous)', () => { // If a refactor renames these files the guard above would silently pass with // nothing to check; assert the owners are present so the guard stays live. const writers = sourceFiles(chatViewDir).filter(({ full }) => { const src = readFileSync(full, 'utf8') return src.includes('.spacer-dynamic') && SPACER_HEIGHT_WRITE.test(src) }).map(f => f.name).sort() - assert.deepEqual(writers, ['preserveTogglePosition.js', 'useScrollMode.js']) + assert.deepEqual(writers, [OWNER]) }) diff --git a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js index 14122da77..454e295a0 100644 --- a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js +++ b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js @@ -585,7 +585,7 @@ test('reader reaching the reserved physical bottom keeps the live pin armed', () }), livePin, 'the existing pin identity stays intact') }) -test('reader reaching reserved bottom repairs a lost live pin instead of following immediately', () => { +test('reader reaching visible latest-user reservation creates a live pin', () => { assert.deepEqual(modeAfterReaderReachesBottom({ mode: { kind: 'FOLLOW_BOTTOM' }, spacerH: 320, @@ -605,7 +605,7 @@ test('reader reaches ordinary bottom only after reservation is exhausted', () => }), { kind: 'FOLLOW_BOTTOM' }) }) -test('idle reserved bottom is a settled pin and cannot manufacture follow', () => { +test('reader reaching visible latest-user reservation creates a settled pin', () => { assert.deepEqual(modeAfterReaderReachesBottom({ mode: { kind: 'ANCHOR_AT', key: 'user-c-123', offset: 4 }, spacerH: 320, @@ -614,17 +614,6 @@ test('idle reserved bottom is a settled pin and cannot manufacture follow', () = }), { kind: 'PIN_USER_MSG', cid: 'c-123' }) }) -test('a bottom edge created by anchor reservation keeps the reader anchor', () => { - const anchor = { kind: 'ANCHOR_AT', key: 'assistant-question', offset: 60 } - assert.equal(modeAfterReaderReachesBottom({ - mode: anchor, - spacerH: 180, - anchorReservation: true, - turnRunning: true, - lastUserCid: 'c-123', - }), anchor) -}) - test('a short settled pin retires automatic follow but keeps its identity', () => { const livePin = { kind: 'PIN_USER_MSG', cid: 'c-123', followWhenFilled: true, @@ -1080,6 +1069,8 @@ test('applyMode PIN resolves the row by its exact data-cid', () => { function makeSpacerScrollEl({ clientHeight, queuedTray = null }) { return { clientHeight, + scrollTop: 0, + querySelector: () => null, parentElement: { querySelector(selector) { if (selector === '.queued') return queuedTray @@ -1089,52 +1080,183 @@ function makeSpacerScrollEl({ clientHeight, queuedTray = null }) { } } -test('spacer reservation is independent from pin mode', () => { +test('spacer reservation belongs to the visible latest user row in any mode', () => { const scrollEl = makeSpacerScrollEl({ clientHeight: 600 }) + scrollEl.scrollTop = 500 const listEl = { offsetHeight: 900 } - const lastUserMsgEl = { offsetTop: 700 } + const lastUserMsgEl = { + offsetTop: 700, + offsetHeight: 80, + dataset: { cid: 'c-1' }, + } assert.equal( - _computeSpacerH(scrollEl, listEl, lastUserMsgEl, 600), + _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + 600, + { kind: 'PIN_USER_MSG', cid: 'c-1' }, + ), + 396, + ) + assert.equal( + _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + 600, + { kind: 'ANCHOR_AT', key: 'a-1', offset: 0 }, + ), + 396, + 'mode does not retire room while the latest user row is visible', + ) + assert.equal( + _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + 600, + { kind: 'PIN_USER_MSG', cid: 'different-row' }, + ), 396, + 'visibility, not stale mode identity, owns the latest row reservation', ) }) -test('spacer reservation returns zero before there is a user message', () => { +test('spacer disappears when only an older user row is visible', () => { const scrollEl = makeSpacerScrollEl({ clientHeight: 600 }) - const listEl = { offsetHeight: 200 } + scrollEl.scrollTop = 0 + const listEl = { offsetHeight: 1400 } + const latestUserMsgEl = { + offsetTop: 900, + offsetHeight: 80, + dataset: { cid: 'latest' }, + } - assert.equal(_computeSpacerH(scrollEl, listEl, null, 600), 0) + assert.equal( + _computeSpacerH( + scrollEl, + listEl, + latestUserMsgEl, + 600, + { kind: 'ANCHOR_AT', key: 'older-user', offset: 0 }, + ), + 0, + 'a visible older row cannot borrow reservation from the off-screen latest row', + ) }) -test('viewport growth keeps a question-answer anchor reachable before output resumes', () => { - const anchor = { offsetTop: 1320, offsetHeight: 220 } +test('applied anchor viewport outranks stale current visibility', () => { + const anchor = { offsetTop: 100, offsetHeight: 80 } const scrollEl = { - clientHeight: 960, + clientHeight: 600, + scrollTop: 500, querySelector(selector) { - return selector === '[data-key="assistant-question"]' ? anchor : null + return selector === '[data-key="older-anchor"]' ? anchor : null }, } - const listEl = { offsetHeight: 1500 } - const lastUserMsgEl = { offsetTop: 900 } - const mode = { - kind: 'ANCHOR_AT', - key: 'assistant-question', - offset: 60, + const latestUserMsgEl = { + offsetTop: 700, + offsetHeight: 80, + dataset: { cid: 'latest' }, } - const spacerH = _computeSpacerH( - scrollEl, listEl, lastUserMsgEl, 960, mode, + assert.equal( + _computeSpacerH( + scrollEl, + { offsetHeight: 1000 }, + latestUserMsgEl, + 600, + { kind: 'ANCHOR_AT', key: 'older-anchor', offset: 0 }, + ), + 0, + 'a pending anchor that hides the latest row must not carry stale room into its applied viewport', ) - const target = anchor.offsetTop - mode.offset - const maxScrollTop = listEl.offsetHeight + spacerH - scrollEl.clientHeight +}) - assert.equal(maxScrollTop, target, - 'the frozen card position is reachable in the first grown-viewport frame') +test('applied anchor may reserve before current geometry reaches its visible latest row', () => { + const anchor = { offsetTop: 600, offsetHeight: 80 } + const scrollEl = { + clientHeight: 600, + scrollTop: 0, + querySelector(selector) { + return selector === '[data-key="latest-anchor"]' ? anchor : null + }, + } + const latestUserMsgEl = { + offsetTop: 700, + offsetHeight: 80, + dataset: { cid: 'latest' }, + } + + assert.equal( + _computeSpacerH( + scrollEl, + { offsetHeight: 900 }, + latestUserMsgEl, + 600, + { kind: 'ANCHOR_AT', key: 'latest-anchor', offset: 100 }, + ), + 396, + 'mount can establish exact room before applying the saved visible-row anchor', + ) +}) + +test('keyboard-closed height cannot make an actually hidden row visible', () => { + const scrollEl = makeSpacerScrollEl({ clientHeight: 400 }) + const latestUserMsgEl = { + offsetTop: 500, + offsetHeight: 80, + dataset: { cid: 'latest' }, + } + + assert.equal( + _computeSpacerH( + scrollEl, + { offsetHeight: 700 }, + latestUserMsgEl, + 800, + { kind: 'INITIAL' }, + ), + 0, + 'fullViewH sizes eligible room but actual clientHeight decides visibility', + ) }) -test('anchor reservation disappears once real content makes the target reachable', () => { - const anchor = { offsetTop: 1320, offsetHeight: 220 } +test('tool expansion consumes reservation and collapse restores the exact deficit', () => { + const scrollEl = makeSpacerScrollEl({ clientHeight: 915 }) + const lastUserMsgEl = { + offsetTop: 200, + offsetHeight: 80, + dataset: { cid: 'latest' }, + } + const mode = { kind: 'ANCHOR_AT', key: 'latest-user', offset: 4 } + + const collapsed = _computeSpacerH( + scrollEl, { offsetHeight: 500 }, lastUserMsgEl, 915, mode, + ) + const expanded = _computeSpacerH( + scrollEl, { offsetHeight: 1300 }, lastUserMsgEl, 915, mode, + ) + const collapsedAgain = _computeSpacerH( + scrollEl, { offsetHeight: 500 }, lastUserMsgEl, 915, mode, + ) + + assert.equal(collapsed, 611) + assert.equal(expanded, 0) + assert.equal(collapsedAgain, collapsed) +}) + +test('spacer reservation returns zero before there is a user message', () => { + const scrollEl = makeSpacerScrollEl({ clientHeight: 600 }) + const listEl = { offsetHeight: 200 } + + assert.equal(_computeSpacerH(scrollEl, listEl, null, 600), 0) +}) + +test('question-answer anchor gets no room when the latest user row is off-screen', () => { + const anchor = { offsetTop: 60, offsetHeight: 220 } const scrollEl = { clientHeight: 960, querySelector(selector) { @@ -1144,15 +1266,23 @@ test('anchor reservation disappears once real content makes the target reachable const mode = { kind: 'ANCHOR_AT', key: 'assistant-question', offset: 60 } assert.equal( - _computeSpacerH(scrollEl, { offsetHeight: 2300 }, { offsetTop: 900 }, 960, mode), + _computeSpacerH( + scrollEl, + { offsetHeight: 1500 }, + { offsetTop: 1100, offsetHeight: 80, dataset: { cid: 'c-1' } }, + 960, + mode, + ), 0, + 'an unreachable anchor clamps to conversation content instead', ) }) -test('a live off-content anchor reserves its exact reader-owned position', () => { +test('an off-content legacy anchor clamps to content then reserves for its visible latest user', () => { const anchor = { offsetTop: 500, offsetHeight: 220 } const scrollEl = { clientHeight: 700, + scrollTop: 1400, querySelector(selector) { return selector === '[data-key="assistant-question"]' ? anchor : null }, @@ -1161,9 +1291,14 @@ test('a live off-content anchor reserves its exact reader-owned position', () => kind: 'ANCHOR_AT', key: 'assistant-question', offset: -900, } assert.equal( - _computeSpacerH(scrollEl, { offsetHeight: 700 }, { offsetTop: 100 }, 700, mode), - 1400, - 'live reader ownership survives in reserved room; persistence rejects it', + _computeSpacerH( + scrollEl, + { offsetHeight: 700 }, + { offsetTop: 100, offsetHeight: 80, dataset: { cid: 'c-1' } }, + 700, + mode, + ), + 96, ) }) @@ -1176,10 +1311,20 @@ test('queued tray does not shorten spacer reservation', () => { } const scrollEl = makeSpacerScrollEl({ clientHeight: 600, queuedTray }) const listEl = { offsetHeight: 900 } - const lastUserMsgEl = { offsetTop: 700 } + const lastUserMsgEl = { + offsetTop: 700, + offsetHeight: 80, + dataset: { cid: 'c-1' }, + } assert.equal( - _computeSpacerH(scrollEl, listEl, lastUserMsgEl, 600), + _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + 600, + { kind: 'PIN_USER_MSG', cid: 'c-1' }, + ), 396, ) }) @@ -1199,8 +1344,17 @@ function pinReachable({ fullViewH, clientHeight, listH, lastUserTop }) { scrollHeight: 0, scrollTop: 0, clientHeight, }) const listEl = { offsetHeight: listH } - const lastUserMsgEl = { offsetTop: lastUserTop } - const spacerH = _computeSpacerH(scrollEl, listEl, lastUserMsgEl, fullViewH) + const lastUserMsgEl = { + offsetTop: lastUserTop, + dataset: { cid: 'pin-row' }, + } + const spacerH = _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + fullViewH, + { kind: 'PIN_USER_MSG', cid: 'pin-row' }, + ) const scrollHeight = listH + spacerH const maxScrollTop = scrollHeight - clientHeight const pinTarget = Math.max(0, lastUserTop - 4) // PIN_OFFSET = 4 @@ -1313,33 +1467,43 @@ test('F1: a collapse-clamped pin is recovered by the settle once the spacer rest // --------------------------------------------------------------------------- -// F2 — spacer reservation survives remount. FOLLOW_BOTTOM must ignore that -// reservable room rather than deleting it to avoid an empty restored viewport. +// F2 — remount reservation follows whether the latest user row is visible. // --------------------------------------------------------------------------- -test('F2: an idle-mounted chat still reserves exactly enough room for its last user row to reach the top', () => { +test('F2: an idle-mounted short chat reserves for its visible latest user', () => { const scrollEl = makeSpacerScrollEl({ clientHeight: 915 }) const listEl = { offsetHeight: 260 } // 2-message short chat, fits the viewport - const lastUserMsgEl = { offsetTop: 200 } - const spacerH = _computeSpacerH(scrollEl, listEl, lastUserMsgEl, 915) - const maxScrollTop = listEl.offsetHeight + spacerH - scrollEl.clientHeight - assert.equal(maxScrollTop, lastUserMsgEl.offsetTop - PIN_OFFSET, - 'remount keeps exactly enough room to lift the last user row, with no excess') + const lastUserMsgEl = { + offsetTop: 200, + offsetHeight: 60, + dataset: { cid: 'c-1' }, + } + const spacerH = _computeSpacerH( + scrollEl, + listEl, + lastUserMsgEl, + 915, + { kind: 'ANCHOR_AT', key: 'a-1', offset: 0 }, + ) + assert.equal(spacerH, 851) }) -test('F2: FOLLOW_BOTTOM ignores permanent spacer room so a short restored chat stays on-screen', () => { - const userTop = 8 +test('F2: a deliberately restored pin owns exact room and keeps its user row visible', () => { const shortList = 260 const clientHeight = 915 const lowUserTop = 200 const spacerH = _computeSpacerH( - { clientHeight }, { offsetHeight: shortList }, { offsetTop: lowUserTop }, clientHeight, + { clientHeight }, + { offsetHeight: shortList }, + { offsetTop: lowUserTop, offsetHeight: 60, dataset: { cid: 'c-1' } }, + clientHeight, + { kind: 'PIN_USER_MSG', cid: 'c-1' }, ) const restored = makePinnableScrollEl({ listH: shortList, spacerH, clientHeight, userTop: lowUserTop, cid: 'c-1' }) - applyMode(restored, { kind: 'FOLLOW_BOTTOM' }) - assert.equal(restored.scrollTop, 0, - 'short real content does not scroll merely because reservable room exists') - assert.ok(userTop - restored.scrollTop >= 0, 'the restored conversation is on-screen') + applyMode(restored, { kind: 'PIN_USER_MSG', cid: 'c-1' }) + assert.equal(restored.scrollTop, lowUserTop - PIN_OFFSET) + assert.equal(restored.scrollHeight - restored.clientHeight, restored.scrollTop, + 'the reservation ends exactly at the visible pin target') }) diff --git a/frontend/src/components/ChatView/chatContract.js b/frontend/src/components/ChatView/chatContract.js index fae02ed74..31b762e5c 100644 --- a/frontend/src/components/ChatView/chatContract.js +++ b/frontend/src/components/ChatView/chatContract.js @@ -103,8 +103,8 @@ export const CHAT_CONTRACT = [ title: 'Hold the reader through disclosure toggles', summary: 'Collapsing or expanding thinking, tool, and activity disclosures keeps ' - + 'the reader anchor fixed and never stacks provisional spacer ' - + 'reservations across rapid toggles.', + + 'the reader anchor fixed. The helper never writes spacer; the sole ' + + 'owner recomputes room only for the visible latest user row.', }, ] @@ -231,31 +231,29 @@ export function cushionPresent(snap, { min = PIN_BOTTOM_ROOM, pinOffset = PIN_OF /** C7 anchor-held-through-toggle: collapsing/expanding a disclosure (a thinking * block, tool card, or activity stretch) must not move the reader. The anchor's - * viewport-top stays put across the toggle (±tolerance), AND the dynamic spacer - * must not accumulate reservation across repeated toggles: after a collapse it - * may grow by at most the one removed body's height, never a stacked multiple - * (the "spacer over-reserves across rapid toggles" regression). This is the - * disclosure seam where preserveTogglePosition and useScrollMode share the - * spacer — the interaction that regressed repeatedly. `bodyH` is the collapsed - * body's outer height; pass 0 for an expand (spacer must not exceed baseline). */ + * viewport-top stays put across the toggle (±tolerance). The disclosure helper + * never writes spacer; the scroll owner may independently consume/restore an + * exact deficit only when the latest user row remains visible. */ export function anchorHeldThroughToggle( - before, after, { tolerance = 2, bodyH = 0, spacerBaseline = null } = {}) { + before, after, { tolerance = 2 } = {}) { const id = 'anchor-held-through-toggle' - const expected = `anchor top drift <= ${tolerance}; spacer <= baseline + bodyH` + const expected = `anchor top drift <= ${tolerance}; positive spacer requires visible latest user` if (!before || !after || before.anchorTop == null || after.anchorTop == null) { return indeterminate(id, expected, 'no anchorTop in before/after (missing disclosure anchor)') } const drift = after.anchorTop - before.anchorTop - const baseline = spacerBaseline ?? before.spacerH - // Spacer accumulation check only when both sides expose a spacer height. - const spacerOk = after.spacerH == null || baseline == null - ? true - : after.spacerH <= baseline + Math.max(0, bodyH) + tolerance + const spacerOk = after.spacerH == null + || after.spacerH <= tolerance + || after.latestUserVisible === true return { ok: Math.abs(drift) <= tolerance && spacerOk, id, expected, - measured: { drift, spacerBefore: baseline, spacerAfter: after.spacerH }, + measured: { + drift, + spacerBefore: before.spacerH, + spacerAfter: after.spacerH, + }, } } diff --git a/frontend/src/components/ChatView/preserveTogglePosition.js b/frontend/src/components/ChatView/preserveTogglePosition.js index dcab150ef..385444d84 100644 --- a/frontend/src/components/ChatView/preserveTogglePosition.js +++ b/frontend/src/components/ChatView/preserveTogglePosition.js @@ -1,20 +1,3 @@ -// A fast close→open can happen before ResizeObserver replaces the provisional -// collapse reservation. Remember the exact baseline so the next toggle can -// unwind only OUR still-present write instead of stacking another body height -// on top. WeakMap keeps the bookkeeping DOM-lifetime scoped. -const provisionalSpacer = new WeakMap() - -function unwindProvisionalSpacer(spacer) { - const pending = provisionalSpacer.get(spacer) - if (!pending) return - // If normal layout already wrote a different value, it owns the spacer now; - // never roll that authoritative calculation back to our stale baseline. - if (Math.abs((spacer.offsetHeight || 0) - pending.primedHeight) <= 1) { - spacer.style.height = `${pending.baselineHeight}px` - } - provisionalSpacer.delete(spacer) -} - export function preserveTogglePosition(anchorEl, bodyEl = anchorEl?.nextElementSibling) { if (!anchorEl || typeof requestAnimationFrame !== 'function') return const scroller = anchorEl.closest?.('.chat__scroll') @@ -27,34 +10,6 @@ export function preserveTogglePosition(anchorEl, bodyEl = anchorEl?.nextElementS // preserve the reader's exact header position below. if (scroller.dataset?.scrollMode === 'FOLLOW_BOTTOM') return - // Closing a disclosure at the physical tail removes height before the chat's - // ResizeObserver can grow its dynamic bottom reservation. The browser clamps - // scrollTop in that gap, paints the header lower for one frame, then the - // observer restores it — the visible down/up twitch. Reserve the body's exact - // outer height synchronously so total scroll height stays constant across the - // React commit; the observer replaces this provisional value with its normal - // spacer calculation on the next layout pass. - const spacer = scroller.querySelector?.('.spacer-dynamic') - if (spacer) unwindProvisionalSpacer(spacer) - - if (anchorEl.getAttribute?.('aria-expanded') === 'true') { - if (spacer && bodyEl) { - const rectHeight = bodyEl.getBoundingClientRect?.().height || 0 - const styles = typeof getComputedStyle === 'function' - ? getComputedStyle(bodyEl) - : null - const marginTop = Number.parseFloat(styles?.marginTop) || 0 - const marginBottom = Number.parseFloat(styles?.marginBottom) || 0 - const removedHeight = rectHeight + marginTop + marginBottom - if (removedHeight > 0) { - const baselineHeight = spacer.offsetHeight || 0 - const primedHeight = baselineHeight + removedHeight - spacer.style.height = `${primedHeight}px` - provisionalSpacer.set(spacer, { baselineHeight, primedHeight }) - } - } - } - const before = anchorEl.getBoundingClientRect().top // A disclosure inserts/removes its body during React's click commit. Observe diff --git a/frontend/src/components/ChatView/useScrollMode.js b/frontend/src/components/ChatView/useScrollMode.js index 41b2b20bd..a4e4ad4c2 100644 --- a/frontend/src/components/ChatView/useScrollMode.js +++ b/frontend/src/components/ChatView/useScrollMode.js @@ -19,11 +19,14 @@ * first visible user message always pins; every later message pins when the * reader is at the real-content tail at submit time. DOM geometry is the * authority; ScrollMode is only a fallback when no scroll element exists. - * A live pin leaves FOLLOW_BOTTOM while its dynamic spacer is - * being consumed, then hands off to FOLLOW_BOTTOM exactly when that - * reservation reaches zero. A short reply never reaches the handoff and - * remains pinned after settle. The dynamic pin spacer is reserved room, not - * message content. + * A live pin leaves FOLLOW_BOTTOM while its dynamic spacer is being consumed, + * then hands off to FOLLOW_BOTTOM exactly when that reservation reaches zero. + * A short reply never reaches the handoff and remains pinned after settle. + * The dynamic spacer belongs exclusively to the latest visible user row. + * It is independent of turn completion: short replies keep the remaining + * room, while reply/tool expansion consumes it and collapse restores it. + * PIN_USER_MSG may reserve before its row lands so a fresh send can pin in + * one frame; every other mode reserves only while that latest row is visible. * Gesture-driven bottom detection reads the scroll container's geometry in * the scroll event itself. There is no second sentinel/observer authority * that can lag behind the reader and contradict the current viewport. @@ -157,11 +160,9 @@ function _topmostVisibleMsg(scrollEl) { * no anchorable message. * * Why this exists: a non-pinning send must not leave a stale PIN_USER_MSG - * behind. The bottom spacer is always reserved for the latest user message, - * but the scrollTop write is still mode-driven. The send sites call this to - * convert a stale PIN into the reader's actual position, so the reader stays - * exactly where they were while the new message still gets bottom room below - * it if they later scroll to the tail. */ + * behind. The send sites call this to convert a stale PIN into the reader's + * actual position. Reservation then follows whether that held viewport still + * shows the latest user row; mode alone neither grants nor retires it. */ export function anchorModeFromScroll(scrollEl) { if (!scrollEl) return null const anchorEl = _topmostVisibleMsg(scrollEl) @@ -201,6 +202,8 @@ export function bottomAnchorModeFromScroll(scrollEl) { const last = items[items.length - 1] const key = last?.dataset?.key if (!last || !key) return null + // Exclude reservation from the anchor calculation. Whether the resulting + // held viewport qualifies for latest-user room is decided separately. const spacerH = scrollEl.querySelector('.spacer-dynamic')?.offsetHeight || 0 const realContentH = scrollEl.scrollHeight - spacerH const targetScrollTop = Math.max(0, realContentH - scrollEl.clientHeight) @@ -245,10 +248,10 @@ export function physicalBottomAnchorModeFromScroll(scrollEl) { /** Freeze a viewport to real conversation content. * - * A reader can move away from the physical bottom while the viewport is - * wholly inside dynamic spacer. There is no exact visible row to anchor in - * that case, but the gesture must still retire live follow. Settle at the - * latest real-content tail rather than leaving FOLLOW_BOTTOM armed. */ + * A reader can begin moving through latest-user reservation. There may be no + * exact visible row in that region, but the gesture must still retire live + * follow. Settle at the latest real-content tail; spacer is then recomputed + * from whether the held viewport still shows the latest user row. */ export function contentHoldModeFromScroll(scrollEl) { return _contentAnchorModeFromScroll(scrollEl) || bottomAnchorModeFromScroll(scrollEl) @@ -292,12 +295,8 @@ export function applyMode(scrollEl, mode) { return } case 'FOLLOW_BOTTOM': - // Follow the bottom of REAL conversation content, not the reservable - // spacer below it. The spacer exists so the latest user row can be - // lifted to the top; treating that blank reservation as content made a - // short restored chat open on an empty viewport. Long content normally - // has a zero-height spacer, so this is identical to the usual bottom - // follow there. + // Mode never owns reservation. Follow the bottom of real content while + // excluding any latest-user room; visibility sizing remains independent. { const spacerH = scrollEl.querySelector('.spacer-dynamic')?.offsetHeight || 0 const realContentH = scrollEl.scrollHeight - spacerH @@ -414,8 +413,8 @@ export function _validateSavedMode(saved, messages, scrollEl) { * * FOLLOW_BOTTOM and PIN_USER_MSG are useful while this mount is active and * are already converted to settled restore modes by `_validateSavedMode` on - * the next mount. ANCHOR_AT is the only mode whose stored geometry can point - * wholly into spacer, so validate that location before every write. */ + * the next mount. ANCHOR_AT can still carry legacy off-content geometry, so + * validate that location before every write. */ export function _modeForPersistence(mode, messages, scrollEl) { return mode?.kind === 'ANCHOR_AT' ? _validateSavedMode(mode, messages, scrollEl) @@ -423,12 +422,62 @@ export function _modeForPersistence(mode, messages, scrollEl) { } -/** Spacer height needed so the latest user message can sit near the - * top of the viewport, with the PIN_OFFSET breathing room above it. While an - * ANCHOR_AT hold is active, also reserve enough room to keep that exact target - * reachable if a mobile viewport grows before new response content arrives. - * The spacer's only job is reserving bottom room — it does NOT touch - * scrollTop and it does NOT decide whether a send pins. +function _rowIntersectsViewport(rowEl, scrollTop, viewH) { + if (!rowEl || !Number.isFinite(scrollTop) || !(viewH > 0)) return false + const rowTop = rowEl.offsetTop + const rowHeight = rowEl.offsetHeight + if (!Number.isFinite(rowTop) || !Number.isFinite(rowHeight)) return false + return rowTop + rowHeight > scrollTop && rowTop < scrollTop + viewH +} + + +/** Whether the latest user row owns reservation in the viewport represented by + * current geometry/mode. A matching PIN_USER_MSG is allowed to reserve before + * the browser can place the fresh row. Other modes must actually show the + * latest user row — either now or at the real-content target they are about to + * apply. Older user rows never participate because the caller passes only the + * DOM tail user row. + */ +function _latestUserOwnsSpacer(scrollEl, listEl, lastUserMsgEl, mode, viewH) { + const rowCid = lastUserMsgEl?.dataset?.cid + if (mode?.kind === 'PIN_USER_MSG' + && rowCid != null + && String(rowCid) === String(mode.cid)) { + return true + } + + let targetScrollTop = null + if (mode?.kind === 'ANCHOR_AT') { + const anchorEl = _anchorEl(scrollEl, mode.key) + if (anchorEl) targetScrollTop = anchorEl.offsetTop - mode.offset + } else if (mode?.kind === 'FOLLOW_BOTTOM') { + targetScrollTop = listEl.offsetHeight - scrollEl.clientHeight + } + if (targetScrollTop == null) { + return _rowIntersectsViewport(lastUserMsgEl, scrollEl.scrollTop, viewH) + } + + // Project against real content only. Spacer cannot make its own owner + // visible; it may only realize room for a row that the held viewport shows. + const maxRealScrollTop = Math.max( + 0, + listEl.offsetHeight - scrollEl.clientHeight, + ) + const clampedTarget = Math.min( + maxRealScrollTop, + Math.max(0, targetScrollTop), + ) + return _rowIntersectsViewport(lastUserMsgEl, clampedTarget, viewH) +} + + +/** Spacer height needed so the latest visible user message can sit near the + * top of the viewport, with the PIN_OFFSET breathing room above it. + * + * Visibility is the defining invariant. The matching latest user pin may + * reserve before placement; every other mode gets room only while its real + * viewport contains that latest row. Turn completion does not retire room. + * Content growth consumes the exact deficit and content collapse restores it. * * Formula: * max(0, viewH + (lastUserMsgTop − PIN_OFFSET) − listH @@ -444,10 +493,8 @@ export function _modeForPersistence(mode, messages, scrollEl) { * the pre-cushion behavior; a >0 value re-adds breathing room if the exact * end-of-scroll rest ever feels cramped.) * - * The permanent pin reservation is intentionally independent from pin mode - * and component lifetime. The anchor addition exists only while ANCHOR_AT - * needs more room than that permanent baseline, and disappears as soon as - * real content makes the anchor naturally reachable. + * Once the latest user row leaves the viewport, reservation collapses. An + * older visible user row never receives it. */ const PIN_OFFSET = 4 const PIN_BOTTOM_ROOM = 0 @@ -458,20 +505,20 @@ export function _computeSpacerH( fullViewH, mode = null, ) { - if (!scrollEl || !listEl) return 0 + if (!scrollEl || !listEl || !lastUserMsgEl) return 0 const viewH = fullViewH || scrollEl.clientHeight - const pinTarget = lastUserMsgEl - ? Math.max(0, lastUserMsgEl.offsetTop - PIN_OFFSET) - : 0 - let target = pinTarget - if (mode?.kind === 'ANCHOR_AT') { - const anchorEl = _anchorEl(scrollEl, mode.key) - if (anchorEl) { - target = Math.max(target, Math.max(0, anchorEl.offsetTop - mode.offset)) - } - } - if (!lastUserMsgEl && target === 0) return 0 - return Math.max(0, viewH + target - listEl.offsetHeight + PIN_BOTTOM_ROOM) + if (!_latestUserOwnsSpacer( + scrollEl, + listEl, + lastUserMsgEl, + mode, + scrollEl.clientHeight || viewH, + )) return 0 + const pinTarget = Math.max(0, lastUserMsgEl.offsetTop - PIN_OFFSET) + return Math.max( + 0, + viewH + pinTarget - listEl.offsetHeight + PIN_BOTTOM_ROOM, + ) } @@ -575,33 +622,18 @@ export function modeAfterTerminalLayout(mode, spacerH, layoutStable) { /** Resolve a reader-owned scroll that reaches the physical bottom. * - * The physical bottom sits AFTER the dynamic reservation. While that spacer - * still exists, reaching it means the latest user row is at its exact pin - * target; it does NOT mean the reader asked to follow the real-content tail - * above the spacer. Conflating those two bottoms made the next ResizeObserver - * tick jump back to the response and follow every token immediately. - * - * During a live turn, reaching the reserved bottom (re-)arms the ordinary - * spacer-exhaustion handoff. In an idle chat it is a settled pin, so a later - * image/font/layout change cannot manufacture live-follow intent. + * Positive spacer means the latest user row is visible (or already pinned). + * Reaching its reserved bottom makes that visible row the explicit pin; + * an ordinary bottom with no reservation enters FOLLOW_BOTTOM. */ export function modeAfterReaderReachesBottom({ mode, spacerH, - anchorReservation = false, turnRunning, lastUserCid, }) { - // An ANCHOR_AT reservation makes that exact reader-owned position the - // physical bottom while the viewport is temporarily taller than the content - // beneath it. Reaching this synthetic edge is not a request to reinterpret - // the position as the latest-user pin; keep the anchor until real content - // makes its target naturally reachable and the extra reservation disappears. - if (anchorReservation && mode?.kind === 'ANCHOR_AT') return mode if (spacerH > 1 && lastUserCid != null) { - if (mode?.kind === 'PIN_USER_MSG' - && mode.cid === lastUserCid - && (!!mode.followWhenFilled || !turnRunning)) { + if (mode?.kind === 'PIN_USER_MSG' && mode.cid === lastUserCid) { return mode } return { @@ -844,8 +876,8 @@ export function mountMediaSettled(scrollEl) { * @param {React.MutableRefObject} args.loadingOlderRef * When true, scroll events from pagination shouldn't mutate mode. * @param {boolean} args.turnRunning - * Whether a live turn can consume an existing reservation. Used only when - * the reader reaches the physical bottom while spacer room remains. + * Whether a reader-owned move to the latest user's reserved bottom should + * arm the ordinary spacer-exhaustion handoff while output is still live. * @param {boolean} args.initialEntryCanReveal * Whether entry has a trustworthy idle cache or settled server history. * @param {boolean} args.initialEntrySettled @@ -888,6 +920,11 @@ export default function useScrollMode({ initialEntrySettled, }) { const [revealed, setRevealed] = useState(false) + // A tiny React mirror reruns the layout effect when a semantic transition + // enters/leaves PIN_USER_MSG before message props necessarily change. + // modeRef remains the synchronous source of truth; visibility still owns + // spacer and this state is not a second mode machine. + const [pinModeActive, setPinModeActive] = useState(false) // Synchronous mirror of `revealed` for reapplyActiveMode, which is called // from a ChatView layout effect (a closure that may pre-date the reveal // flip). Set inline at every setRevealed(true) so the read is never stale. @@ -997,6 +1034,11 @@ export default function useScrollMode({ const previousMode = modeRef.current if (nextMode === previousMode) return previousMode modeRef.current = nextMode + const pinOwnedBefore = previousMode?.kind === 'PIN_USER_MSG' + const pinOwnedAfter = nextMode?.kind === 'PIN_USER_MSG' + if (pinOwnedBefore !== pinOwnedAfter) { + setPinModeActive(pinOwnedAfter) + } const scrollEl = scrollRef.current if (scrollEl) scrollEl.dataset.scrollMode = nextMode.kind recordTrace('transitions', event, { @@ -1333,8 +1375,10 @@ export default function useScrollMode({ // reply arrives ("subsequent messages don't get enough space"). The DOM's // last user row is the same element the ref points at once it re-attaches, // so reserving from it keeps the pin target reachable the instant the row - // exists. Null only when there is genuinely no user message → spacer 0. - const lastUserEl = lastUserMsgRef.current || _lastUserRowEl(scrollEl) + // exists. `_computeSpacerH` still requires the latest row to be visible + // at the current/applied viewport, so this fallback cannot grant room to + // an older row or an unrelated reading location. + const lastUserEl = _lastUserRowEl(scrollEl) || lastUserMsgRef.current const h = _computeSpacerH( scrollEl, listEl, lastUserEl, fullViewHRef.current, modeRef.current, ) @@ -1727,18 +1771,12 @@ export default function useScrollMode({ if (atBottom) { const spacerH = spacerEl.offsetHeight || 0 - const lastUserEl = lastUserMsgRef.current || _lastUserRowEl(scrollEl) + const lastUserEl = _lastUserRowEl(scrollEl) || lastUserMsgRef.current const lastUserCid = lastUserEl?.dataset?.cid ?? null - const pinSpacerH = _computeSpacerH( - scrollEl, listEl, lastUserEl, fullViewHRef.current, - ) - const anchorReservation = modeRef.current?.kind === 'ANCHOR_AT' - && spacerH > pinSpacerH + 1 transitionMode( modeAfterReaderReachesBottom({ mode: modeRef.current, spacerH, - anchorReservation, turnRunning: turnRunningRef.current, lastUserCid, }), @@ -1753,6 +1791,10 @@ export default function useScrollMode({ if (anchor) transitionMode(anchor, 'reader:hold-anchor') } persistMode() + // Visibility, not mode, owns reservation. Recompute after the gesture + // yields so scrolling the latest user row on/off screen changes spacer + // without mutating geometry during the gesture itself. + sizeSpacer() } scrollEl.addEventListener('scroll', onScroll, { passive: true }) @@ -1806,6 +1848,7 @@ export default function useScrollMode({ chatId, initialEntryCanReveal, initialEntrySettled, + pinModeActive, syncComposerGeometry, ]) @@ -1878,7 +1921,7 @@ export default function useScrollMode({ const listEl = scrollEl.querySelector('.chat__list') const spacerEl = spacerRef.current - const lastUserEl = lastUserMsgRef.current || _lastUserRowEl(scrollEl) + const lastUserEl = _lastUserRowEl(scrollEl) || lastUserMsgRef.current if (!listEl || !spacerEl || !lastUserEl) { transitionMode(settledPinMode(mode), 'terminal:missing-layout-settle') persistMode() @@ -1886,7 +1929,7 @@ export default function useScrollMode({ } const spacerH = _computeSpacerH( - scrollEl, listEl, lastUserEl, fullViewHRef.current, + scrollEl, listEl, lastUserEl, fullViewHRef.current, mode, ) const signature = [ Math.round(listEl.offsetHeight),