Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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)
})
13 changes: 9 additions & 4 deletions frontend/src/components/ChatView/__tests__/chatContract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
}
})
Loading
Loading