Skip to content

feat(conversation): add Conversation and its scroll anchoring - #114

Merged
TheGreatAxios merged 12 commits into
conversation/01-contractfrom
conversation/05-conversation
Oct 28, 2026
Merged

TheGreatAxios merged 12 commits into
conversation/01-contractfrom
conversation/05-conversation

Conversation

@pratikbuilds

@pratikbuilds pratikbuilds commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Conversation owns the scroll container so every chat surface scrolls the same way. Scroll policy is a prop: settle lands a sent message at the top and never moves while text streams; stick follows the end while the reader is there. Layout shifts never scroll, and only the reader's own input breaks a follow.

The anchor algorithm and settle geometry are pure functions, and an anchor correction also applies mid-glide so a glide lands where it meant to. useConversation() shares an item registry, scrollToItem, scrollToEnd, captureAnchor/restoreAnchor, the scroll container and a per-frame scroll snapshot that tells the reader's scrolls from the engine's. The context value keeps its identity across scrolls.

Stacked on #113: review only this commit. Part of #94.

Notes for review

  • settle is built from the spec text (the reference design only sticks to the bottom); not yet compared side by side with other assistant chat UIs.
  • ConversationItem keys by itemId (an id prop would shadow the HTML id).
  • Scrolling lives in an imperative controller outside React; the context value is that object, so its identity never changes; atBottom updates at most once per frame.
  • Loading history: captureAnchor() before prepend, restoreAnchor() in a layout effect after commit; trimming above needs no calls.
  • Both policies start by following the end; settle stops following at the first send or the first scroll away.

Size

Over the 3,000-line triage limit (3,279 lines in 9 files). About 1,300 lines are the scroll engine and its components; the rest is unit tests (about 1,100), browser specs (about 570) and stories. The engine is one unit, so it isn't split.

This PR has two commits: the component, and a follow-up fix that releases a ConversationItem when React 18 detaches it (React 18 ignores a cleanup returned from a ref).

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #99

Conversation owns the scroll container so every chat surface scrolls
the same way. Scroll policy is a prop: settle lands a sent message at
the top and never moves while text streams; stick follows the end while
the reader is there. Layout shifts never scroll, and only the reader's
own input breaks a follow.

The anchor algorithm and settle geometry are pure functions, and an
anchor correction also applies mid-glide so a glide lands where it
meant to. useConversation() shares an item registry, scrollToItem,
scrollToEnd, captureAnchor/restoreAnchor, the scroll container and a
per-frame scroll snapshot that tells the reader's scrolls from the
engine's. The context value keeps its identity across scrolls.
@corbits-triage

corbits-triage Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Superseded by the comment below.

@pratikbuilds

This comment has been minimized.

React 18 ignores a cleanup returned from a ref and calls the ref with
null, so an unmounted item stayed registered and scroll anchoring
measured a detached node.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated multi-agent review: independent review + second-agent validation; only two-agent-confirmed items listed. The scroll engine itself is strong — one contract-level blocker found and verified by executing the real engine code in a harness.

Blocking (VERIFIED, executed harness against the branch engine)

  • A scrollbar drag while the view is following is classified as a layout shift and actively reversed: the engine writes endTop() on every drag step (src/ui/conversation.tsx:458-495, :509-512; src/lib/conversation-scroll.ts:245-259). Scrollbar drags dispatch no page pointer events in Chromium (compositor-consumed) or Firefox, so pointerHeld never latches, no wheel/key/touch fires, and classifyScroll returns "layout" — the reader cannot scroll away with the scrollbar in either policy; in stick the follow is never broken by it at all. Suggested shape: default-deny — an unexplained scroll away from the end, outside the resize window (now - resizedAt <= 100), is the reader's. Regression test: scroll with no preceding input event while following, assert no write back to endTop.

Should-fix (VERIFIED, executed harness)

  • Scroll events can never cancel a glide: reader-classified momentum mid-glide is recorded as the reader's in the snapshot yet the glide overwrites it and lands at its target (contradicting glide's own doc); past the 150 ms quiet window, momentum instead classifies as layout because the early-return precedes extendGesture (conversation.tsx:474 vs :468/476-483/533). Settle the precedence once.
  • reserved survives remount while the fresh spacer has height 0; when the recomputed reserve equals the stale value, updateReserve early-returns and the spacer is never re-synced — scrollToEnd() lands ~reserve short while atBottom reports true (conversation.tsx:345-365). Reset reserved on attach.

Minor

  • ConversationScrollButton unmounts when atBottom flips true; a focused keyboard reader falls to <body> — return focus to the log in the button's unmount path when it held focus (conversation.tsx:905/919).
  • File-for-later: non-scroll keydowns open the gesture window and can misattribute layout scrolls (:505-507); reserve collapses to 0 with no hand-over when the settled item unregisters; sentId seed contract, setPolicy mid-life switch and ConversationEmptyState lack harness/test coverage.

@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review. On the blocking item (a scrollbar drag while following is classified as a layout shift and reversed), we couldn't reproduce it in any engine:

Browser How Result
Chromium Playwright, real drag on a classic 14px scrollbar thumb pointerdown and mousedown fire on the log when the scrollbar is pressed; drag moved scrollTop 1865 → 778 and it stayed there after release
Firefox Playwright (headed), real drag on a classic 15px scrollbar thumb pointerdown and mousedown fire; drag moved 1865 → 805 and it stayed
Brave, Safari by hand, Chat / Conversation / Long and Streaming Stick stories the view stays where the thumb is dropped; no snap-back

Pressing the scrollbar does dispatch pointerdown on the scroll container in Chromium and Firefox, so the engine latches the reader's pointer and the drag counts as the reader's scroll. A harness that sets scrollTop with no input event at all would see the "layout" path, but a real scrollbar drag doesn't produce that sequence. We're treating this one as not a bug; the remaining items on this PR are being verified separately.

@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Follow-up to the comment above: the scrollbar case doesn't reproduce (now also checked in WebKit with a custom scrollbar: pointerdown fires on the log every time), but the underlying gap you pointed at is real for scrolls that arrive with no input event. While the view is following the end:

  • moving focus to an earlier item (element.focus()) scrolls it into view and the engine reverses it, in Chromium and Firefox;
  • find-in-page in Firefox (window.find) is reversed the same way.

That contradicts the engine's own comment that find-in-page and focus are kept. We'll fix it along the lines you suggested: treat an unexplained scroll away from the end, outside the post-resize window, as the reader's, with a regression test for both cases. Thanks for flagging it.

@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. The scrollbar-drag case is covered in the two comments above. Here's what happened to the rest.

Fixed

1. A scroll that comes from the page, not the reader, got snapped back to the end (this is the real part of the blocking item)

  • The problem: while the view followed new messages, any scroll without wheel, touch, key or pointer input was treated as a layout shift and undone. That covered focusing an earlier message, find in page, and a host calling scrollTo, so all of them jumped straight back to the bottom.
  • The fix: that kind of scroll is now kept, and it stops the follow. It's still undone in two cases: when it happens within 100 ms of a resize (that's the browser clamping the view), or when it stays at the end. byReader still means real input only, so the history window keeps loading earlier messages only when the reader scrolls.
  • Tests: unit tests, plus Playwright cases that focus an earlier message under both scroll policies. Before the fix, focus ended up 1364 px off screen.

2. Scroll events could never cancel a glide (should-fix)

  • Fix: a reader scroll during a "jump to latest" glide now cancels the glide and counts as the reader's, so momentum that was already running wins.

3. After a remount, scrollToEnd() landed short of the end (should-fix)

  • Fix: the reserved space is reset and recalculated when the log attaches. Before the fix, the end landed 284 px short.

4. The "Scroll to latest" button dropped focus to the page when it disappeared (minor)

  • Fix: if the button has focus when it leaves, focus moves to the log first. A ref the host passes in is still attached.

5. The file-for-later items, done now:

  • Only keys that scroll (arrows, Page Up/Down, Home, End, Space) count as the reader scrolling. Other keys no longer do.
  • When the message that was sent leaves the list, the reserved space shrinks only as far as needed, so the view doesn't jump. Before, it moved 284 px.
  • A sentId that matches no rendered message is ignored.
  • New tests cover the sentId starting value, switching scrollPolicy while mounted, and ConversationEmptyState.

Commit: fix(conversation): keep page scrolls and settle glide precedence

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review · Request changes

Adds Conversation and the scroll engine: settle pins a sent message, stick follows the end, and only the reader's own input should break a follow.

Findings

  • src/ui/conversation.tsx:535 — A key or touch on a control inside an item cancels a glide. onKeyDown calls openGesture for Space, arrows, Page, Home, and End on anything that is not an input (isEditable at :198 does not treat a button as editable). onTouchStart (:553) does not look at the target at all, and sets touchHeld, so the next scroll is the reader's. onPointerDown (:539) already ignores a press that is not on the log itself. Descendants should get the same check: a button's Space or a tap inside a message is not a scroll, and it currently freezes settle or "scroll to latest" mid-glide.

  • src/ui/conversation.tsx:517 — A focus or find-in-page scroll within 100ms of a resize is snapped back to the end while following. The comment says a scroll that leaves the end is kept and breaks the follow. The branch does the opposite: afterResize writes endTop() and returns before following can become false. Streaming resizes the log for the whole reply, so that window stays open. Only treat the resize's own clamp as disposable; a scroll that lands away from the end should stick and clear following.

Any scroll within 100ms of a resize was taken for the resize's clamp.
Streaming resizes on every chunk, so while a reply wrote, a focus or
find-in-page scroll was undone: written back to the end while
following, or restored to the old anchor by the next chunk after a
send. With the browser's anchoring off, a resize moves the view only
by clamping it to the last reachable position, so only a scroll that
lands there is skipped now. Any other is kept and breaks a follow.

Space on a button or similar control inside the log activates it, not
scrolls, yet it cancelled a glide in flight. Its other scroll keys and
touch still do, since they really scroll the log.

scrollToItem takes an onEnd callback that reports whether the glide
arrived, so a caller can tell a jump cut short from one that landed.
The test slept in real time and read the wall clock, so on a loaded
machine a gap between two scrolls could outlast the gesture's quiet
window and end the reader's gesture before the cap. It now moves
performance.now itself, 100ms per scroll, and checks every scroll on
each side of the two-second cap.
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. The second point is fixed, and so is part of the first. The rest of the first is kept on purpose, for the reasons below.

1. A key or a tap on a control inside a message stopped a glide

  • The fix: Space on a button or a similar control inside the log no longer counts as the reader scrolling, because it activates the control and doesn't scroll. This covers button, summary, and the button, checkbox, switch, radio, tab, menuitem and option roles. Links are left out, because Space on a link does scroll.
  • Not changed: the other keys, and touch.
    • In Chromium, arrows, Page Up/Down, Home and End on a focused button really do scroll the log, so they should still stop a glide.
    • A touch scroll always starts on content inside the log, never on the log itself. If touch were filtered by target, almost no real flick would be recognised as the reader's, and its momentum would be taken for layout.
    • A finger landing during a glide stops it, which is also what native scroll views do.
  • Test: "Space on a control inside an item leaves a glide alone". Space on an item's button lets the glide land, and ArrowUp on the same button still stops it. It failed before the fix.

2. A focus or find-in-page scroll right after a resize was undone

  • The problem: it's wider than reported. Streaming resizes the log on every chunk, so the 100 ms window was open for almost the whole reply:
    • while following, the scroll was written back to the end;
    • under settle, after a send, the scroll was never recorded, so the next chunk put the old position back.
  • The fix: a resize's clamp is now recognised by where it lands. With the browser's scroll anchoring off, a resize can only move the view by clamping it to the last reachable position. So only a scroll within 100 ms of a resize that lands there is treated as the clamp. Any other scroll is kept, and it breaks the follow.
  • Tests:
    • "following, a page scroll away from the end is kept (stick)" failed before the fix.
    • "not following, a page scroll survives the next resize (settle)" failed before the fix.
    • "the resize's own clamp is not taken as where the reader is" checks that a real clamp is still ignored.

Also in this PR

  • scrollToItem takes an optional onEnd callback that reports whether the glide arrived or was cut short. feat(conversation): add ConversationMap and useReadingPosition #123 uses it for its map fix. The test "reports arriving, being cancelled and being replaced" covers it.
  • A second commit steadies a flaky test. "scrolls after the momentum cap are not the reader's" used real time, so it could fail on a loaded machine. It now drives performance.now itself, 100 ms per scroll, and checks every scroll on each side of the two-second cap.

Commits: fix(conversation): keep page scrolls mid-stream; let space activate, test(conversation): run the momentum-cap test on a set clock

ConversationContent, ConversationItem and ConversationScrollButton
read ref from their props, which only React 19 passes to a function
component. Under React 18, inside the peer range, a host ref on any
of them stayed null and React warned that ref is not a prop. They are
forwardRef components now, so the ref reaches the scroll container,
the item and the button under both versions.
When ConversationContent mounted again inside the same Conversation,
attach wrote the end only while the view followed and otherwise kept
the new container's scrollTop of 0. After any send under settle, or
once the reader left the end under stick, a remount (a key change, a
panel that unmounts its log) opened at the oldest loaded message.
attach now restores the held anchor, which the new items answer to
under the same ids, and still writes the end while following.
scrollToItem dropped the follow before the glide found there was no
such item, and starting that glide cancelled any glide in flight. A
call for an id outside the mounted window moved nothing and reported
onEnd(false), yet under stick a streaming reply then ran off the
bottom of the view, and a jump already under way stopped short.
scrollToItem now reports onEnd(false) for an unmounted id and returns
before touching the follow or the glide in flight.
ConversationEmptyState centres with flex-1 and justify-center, but
inside ConversationContent its parent is the content column, which is
only as tall as its content. With no free space to grow into, the
empty state sat at the top of the log. The column now fills the view
while it holds an empty state, so the empty state centres in it.
Without a label, ConversationDivider was a div with role separator
around a decorative span. Its rule is an hr now, styled as before, so
the separator is a real element and the div carries no role. The
labelled form is unchanged: its label is read in place and its rules
stay decoration.
@pratikbuilds
pratikbuilds force-pushed the conversation/05-conversation branch from 560dc7a to 137ada2 Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

A fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.

  • 447b405: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers ConversationContent, ConversationItem and ConversationScrollButton; checked by rendering the built package under react@18.3.1.
  • 51c9fc6: A remounted ConversationContent opened at the oldest message unless it was following. attach now restores the held anchor, so the reader comes back to the same item at the same offset.
  • fc34cd7: scrollToItem with an id that has no mounted item cleared following and cancelled a glide in flight. It now changes nothing.
  • 2ceb350: ConversationEmptyState did not centre because the content column had no height. The column fills the view while it holds an empty state.
  • 137ada2: The bare divider was a div role="separator"; it now draws a real <hr>.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

scrollToItem kept calling onEnd(true) after the item unmounted and the
view never moved. End that glide as not arrived.

@corbits-triage corbits-triage Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triage requests changes:

  • CI green: required checks are failing

The probe assigns the API from a child, so a null check narrowed it
to never under tsc.
@corbits-triage
corbits-triage Bot dismissed their stale review October 28, 2026 21:03

Nothing triage asked to change still fails, so this request no longer stands.

@TheGreatAxios
TheGreatAxios removed this pull request from stack #130 October 28, 2026 21:37
@TheGreatAxios
TheGreatAxios added this pull request to stack #139 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios force-pushed the conversation/05-conversation branch from 8fb7318 to 167a6a4 Compare October 28, 2026 21:42
@TheGreatAxios
TheGreatAxios dismissed their stale review October 28, 2026 21:49

Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.

@corbits-triage corbits-triage Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triage requests changes:

  • CI green: required checks are failing

@TheGreatAxios
TheGreatAxios merged commit 9582a6d into main Oct 28, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Conversation and its scrolling

2 participants