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
37 changes: 26 additions & 11 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ automatically armed by installing Möbius.

## Chat scroll + steer contract

**Owner-authoritative contract — v1.12 (2026-07-31).** This section is the
**Owner-authoritative contract — v1.13 (2026-08-01).** 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 @@ -536,9 +536,11 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
position). Auto-scroll engages only through (a) the gesture-gated scroll handler
after the user manually reaches an ordinary bottom with no reservation remaining,
or (b) the live-send pin handoff when the streaming reply has consumed its exact
reserved room. The physical bottom while reservation remains is the prompt's pin
target, not the real-content tail: reaching it preserves (or repairs) pin hold and
waits for the spacer-exhaustion handoff. A viewport /
reserved room. Only a send may create `PIN_USER_MSG`. The physical bottom while
reservation remains is an exact reader-owned `ANCHOR_AT` position—including a
negative row offset when the viewport is inside reserved reply room—not a pin or
the real-content tail. Reaching it must preserve that exact physical position and
must never manufacture the pin's spacer-exhaustion handoff. A viewport /
keyboard change, foreground return, mount, or chat restoration must never create
auto-scroll.
- **R1 — Stable latest-turn reservation.** Dynamic bottom spacer derives from the
Expand Down Expand Up @@ -582,7 +584,9 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
mobile-keyboard open/close cycle even though the full-height reservation makes its
scroll position temporarily look away from the physical bottom; viewport geometry
is not reader intent, so apart from the explicit filled-reservation handoff, only a
gesture-gated reader scroll may retire the pin.
gesture-gated reader scroll may retire the pin. A retired or saved pin restores
only as an ordinary `ANCHOR_AT`; pin ownership is never reconstructed by layout,
lifecycle restoration, or a reader gesture.
Terminal promotion makes this decision against the committed settled DOM,
before paint, so a final browser clamp cannot race the pin or its exact
filled-reservation handoff.
Expand Down Expand Up @@ -629,6 +633,14 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
geometry, while a disclosure first settles any preceding gesture and then owns
layout caused by its own expansion/collapse. A bounded dead-man remains the final
escape hatch for any interrupted no-scroll gesture.
The first actual scroll event owned by each gesture also advances one monotonic
reader-intent generation. Every direct scroll write and every indirect geometry
write that can clamp scrolling (dynamic spacer height and composer clearance)
must commit through the scroll controller only when both its captured generation
is still current and the gesture gate is open. Deferred layout captured before a
newer gesture is rejected; once that gesture settles, the controller adopts the
current semantic location and performs one fresh geometry reconciliation. Waiting
for the timing gate to expire never gives stale work its authority back.
A marked Q&A custom-answer field is the deliberate exception to "ordinary
typing cannot scroll": changing its value can grow the field and cause the
browser to move the transcript to keep the native caret visible. Only an
Expand Down Expand Up @@ -694,8 +706,7 @@ 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 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 physical bottom while latest-user reservation remains | any | exact `ANCHOR_AT` | User-owned; preserve the numeric physical position, including negative anchor offset |
| 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 |
Expand All @@ -717,10 +728,14 @@ Controller structure is part of the contract, not an implementation detail:
- `ChatView` may read `modeRef` for a submit snapshot but must not assign it.
It emits send, queue, pagination, and lifecycle events through the semantic
methods returned by `useScrollMode`.
- 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
- Every live mode mutation goes through `transitionMode`, whose entry guard permits
new pins only from send and new follow only from an unreserved-bottom gesture or
an already-armed pin's filled-reservation handoff. 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 and the
composer-clearance CSS geometry. Those indirect writes and every `writeMode`
call share R5's reader-generation commit gate. Spacer height is
derived from the latest user row and exact tail deficit;
disclosure helpers and renderers may preserve an on-screen anchor but may never
prime, enlarge, or unwind spacer themselves.
Expand Down
26 changes: 8 additions & 18 deletions frontend/src/components/ChatView/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,19 +473,11 @@
// current() to fire the bar's hidden picker. ChatInputBar's layout
// effect installs the function.
const attachTriggerRef = useRef(null)
// Refs for the absolutely-positioned foot. A ResizeObserver
// measures `.chat__foot` and publishes its height as `--composer-h`
// on `.chat`, which `.chat__list` reads for its bottom padding so
// chips/queue/multi-line growth keep the last message visible
// above the pill.
// Refs for the absolutely-positioned foot. Its ResizeObserver notifies the
// scroll controller, which owns publishing composer clearance together with
// every other indirect scroll-geometry write.
const chatRef = useRef(null)
const footRef = useRef(null)
const measureComposerHeight = useCallback(() => {
const chatEl = chatRef.current
const footEl = footRef.current
if (!chatEl || !footEl) return
chatEl.style.setProperty('--composer-h', `${footEl.offsetHeight}px`)
}, [])

// One explicit Shell-to-composer handoff owns both New-chat focus and drafts
// supplied by app navigation. Storage restores unmounted chats; applying the
Expand Down Expand Up @@ -527,7 +519,7 @@
cancelled = true
cancelAnimationFrame(raf)
}
}, [chatId, composerRequest, embedded, onComposerRequestHandled])

Check warning on line 522 in frontend/src/components/ChatView/ChatView.jsx

View workflow job for this annotation

GitHub Actions / frontend-unit

React Hook useEffect has missing dependencies: 'handleComposerInputChange' and 'inputValueRef'. Either include them or remove the dependency array

// Lifecycle guards. `hadMessagesRef` reflects the cached length so
// doSend's "first message" branch doesn't fire spuriously.
Expand Down Expand Up @@ -657,12 +649,12 @@
scrollRef,
spacerRef,
lastUserMsgRef,
syncComposerGeometry: measureComposerHeight,
chatRef,
footRef,
messages,
messagesRef,
pendingMessagesLength: pendingQueue.pendingMessages.length,
loadingOlderRef: loadingOlder,
turnRunning: sending || serverRunning,
initialEntryPhase,
})

Expand Down Expand Up @@ -844,7 +836,7 @@
// an authoritative idle verdict.
return null
}
}, [

Check warning on line 839 in frontend/src/components/ChatView/ChatView.jsx

View workflow job for this annotation

GitHub Actions / frontend-unit

React Hook useCallback has missing dependencies: 'isStreamingRef', 'messagesRef', 'offsetRef', 'pendingQueue', and 'setServerRunningState'. Either include them or remove the dependency array
chatId,
commitMessages,
pendingQueue.hydrate,
Expand Down Expand Up @@ -905,7 +897,7 @@
pendingQueue.hydrate(serverPending)
}
} catch { /* background reconciliation is best-effort */ }
}, [

Check warning on line 900 in frontend/src/components/ChatView/ChatView.jsx

View workflow job for this annotation

GitHub Actions / frontend-unit

React Hook useCallback has missing dependencies: 'isStreamingRef', 'pendingQueue', and 'setServerRunningState'. Either include them or remove the dependency array
chatId,
pendingQueue.hydrate,
pendingQueue.pendingMessagesRef,
Expand Down Expand Up @@ -1236,7 +1228,7 @@
queueMicrotask(reconcileExternalActivity)
}
}
}, [connectToStream, embedded, fetchMessages, isStreamingRef])

Check warning on line 1231 in frontend/src/components/ChatView/ChatView.jsx

View workflow job for this annotation

GitHub Actions / frontend-unit

React Hook useCallback has a missing dependency: 'setServerRunningState'. Either include it or remove the dependency array
useEffect(() => {
if (hidden) return
reconcileExternalActivity()
Expand Down Expand Up @@ -1294,7 +1286,7 @@
// owner does not receive input events, so reconcile from the durable draft at
// the visibility boundary before its first painted frame.
restoreDurableDraft()
}, [

Check warning on line 1289 in frontend/src/components/ChatView/ChatView.jsx

View workflow job for this annotation

GitHub Actions / frontend-unit

React Hook useLayoutEffect has missing dependencies: 'messagesRef', 'offsetRef', 'pendingQueue', and 'setServerRunningState'. Either include them or remove the dependency array
chatId,
commitMessages,
hidden,
Expand Down Expand Up @@ -1443,11 +1435,9 @@
if (el && !hidden) reconcileComposerTextarea(el, input)
}, [chatId, hidden, input])

// Publish `.chat__foot`'s rendered height as `--composer-h` on
// `.chat`. `.chat__list` reads this var for its bottom padding so
// the last message always clears the absolutely-positioned pill
// — chips, queue tray, multi-line growth all push the clearance
// in lockstep.
// Notify the scroll owner when `.chat__foot` geometry may have changed.
// The controller publishes the matching list clearance and spacer in one
// guarded layout pass so an observer cannot move the reader indirectly.
useEffect(() => {
const footEl = footRef.current
if (!footEl) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test('owner contract freezes question answers without locking keyboard movement'
new URL('../../../../../ARCHITECTURE.md', import.meta.url),
'utf8',
)
assert.match(architecture, /Owner-authoritative contract — v1\.12 \(2026-07-31\)/)
assert.match(architecture, /Owner-authoritative contract — v1\.13 \(2026-08-01\)/)
assert.match(
architecture,
/In-process question is answered \| any \| transient `ANCHOR_AT` over the prior mode; same active assistant row/,
Expand Down
66 changes: 63 additions & 3 deletions frontend/src/components/ChatView/__tests__/scrollOwnership.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ test('the sole spacer owner still exists (guard is not vacuous)', () => {
assert.deepEqual(writers, [OWNER])
})

test('only the scroll owner publishes composer clearance geometry', () => {
const write = /style\.setProperty\(\s*['"]--composer-h['"]/
const writers = sourceFiles(chatViewDir).filter(({ full }) => (
write.test(readFileSync(full, 'utf8'))
)).map(f => f.name).sort()
assert.deepEqual(writers, [OWNER],
'composer clearance can clamp scrollTop indirectly, so route it through '
+ 'the same reader-authority gate as spacer and direct scroll writes')
})

test('gesture scroll frames defer anchor, spacer, and persistence work until settle', () => {
const start = ownerSource.indexOf('const onScroll = () => {')
const end = ownerSource.indexOf(
Expand Down Expand Up @@ -86,17 +96,67 @@ test('gesture scroll frames defer anchor, spacer, and persistence work until set
const settlePath = ownerSource.slice(settleStart, settleEnd)
assert.ok(settleStart >= 0 && settleEnd > settleStart,
'reader settlement path must remain discoverable')
assert.match(settlePath, /contentHoldModeFromScroll/)
assert.match(settlePath, /if \(settledAtBottom\)/)
assert.match(settlePath, /anchorModeFromScroll/)
assert.match(settlePath, /modeAfterReaderGesture/)
assert.match(settlePath, /hasReservedTail:\s*spacerH\s*>\s*1/)
assert.match(settlePath, /persistMode\(\)/)
assert.match(settlePath, /sizeSpacer\(\)/)
assert.match(settlePath, /sizeSpacer\(currentAuthority\(\)\)/)
assert.doesNotMatch(settlePath, /PIN_USER_MSG|contentHoldModeFromScroll/,
'a reader gesture may hold or follow but must never recreate pin authority')
assert.doesNotMatch(
settlePath,
/scrollEl\.scrollHeight\s*>\s*scrollEl\.clientHeight/,
'a live spacer collapse must not leave the pre-gesture pin armed',
)
})

test('every automatic geometry owner shares the reader-generation gate', () => {
const writeStart = ownerSource.indexOf('const writeMode = useCallback(')
const writeEnd = ownerSource.indexOf('const persistMode =', writeStart)
const writePath = ownerSource.slice(writeStart, writeEnd)
assert.match(writePath, /scrollAuthorityAllowsCommit/,
'direct scroll writes must reject stale generations')

const spacerStart = ownerSource.indexOf('function sizeSpacer(')
const spacerEnd = ownerSource.indexOf('function maybeApplyMode(', spacerStart)
const spacerPath = ownerSource.slice(spacerStart, spacerEnd)
assert.match(spacerPath, /layoutOwnsScroll\(authorityVersion\)/)
assert.ok(
spacerPath.indexOf('layoutOwnsScroll(authorityVersion)')
< spacerPath.indexOf("style.setProperty('--composer-h'"),
'composer clearance must be gated before it mutates layout',
)
assert.ok(
spacerPath.indexOf('layoutOwnsScroll(authorityVersion)')
< spacerPath.indexOf('spacerEl.style.height ='),
'spacer height must be gated before it mutates layout',
)

const terminalStart = ownerSource.indexOf('const settleStreamingPin =')
const terminalEnd = ownerSource.indexOf('const paneResized =', terminalStart)
const terminalPath = ownerSource.slice(terminalStart, terminalEnd)
assert.match(terminalPath, /terminalAuthorityVersion/)
assert.match(terminalPath, /scrollAuthorityAllowsCommit/,
'terminal rAF work must reject a later reader generation')

const hotStart = ownerSource.indexOf('const onScroll = () => {')
const hotEnd = ownerSource.indexOf(
"scrollEl.addEventListener('scroll', onScroll",
hotStart,
)
const hotPath = ownerSource.slice(hotStart, hotEnd)
assert.match(
hotPath,
/readerIntentAfterScroll\(\{/,
'actual scrolls must claim generations by input sequence, not quiet batch',
)

assert.match(terminalPath, /authority === 'wait'/)
assert.match(terminalPath, /requestAnimationFrame\(inspectCommittedLayout\)/,
'terminal settlement must wait through a no-scroll tap instead of retiring pin')
assert.doesNotMatch(terminalPath, /terminal:reader-owns/)
})

test('newer semantic actions cannot be overwritten by an older quiet settlement', () => {
const supersedeStart = ownerSource.indexOf(
'const supersedePendingReaderGesture =',
Expand Down
Loading
Loading