Skip to content

fix(chat): count a held scrollbar drag as scroll intent, and name the follow allowances - #1902

Merged
devswha merged 4 commits into
mainfrom
fix/chat-scroll-focus-followups
Oct 24, 2026
Merged

devswha merged 4 commits into
mainfrom
fix/chat-scroll-focus-followups

Conversation

@devswha

@devswha devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner

What it does for the user

Holding the chat's scrollbar still for a moment and then dragging it up a little now stops Chat from following new output, as any other upward scroll does. Before this, only a drag that began within half a second of the press counted. A thumb that rested first and then moved up less than 48 px kept following, so the next update pulled the reader back to the end.

Fixes #1434. #1734 already fixed items 1, 2, 4 and 5. This PR fixes what was left of item 3:

  • A held scrollbar press (3a). In Chromium a thumb drag sends pointerdown on the scroller at the press, no pointermove while the thumb moves, then scroll events, then pointerup at release (measured in Playwright's Chromium with scrollbars shown). ChatView now remembers that a primary-button press on the transcript's scrollbar is held. While it is held, an upward move counts as the reader's own, however long the thumb rested first. The hold ends on pointerup/pointercancel, on a new press, on any pointer event with no button down (a release this page did not see), and when the chat's input listeners are removed (the pane hidden or left).
  • The 1 px and 48 px values (3c) are named: READER_ALLOWANCE_PX (endpoint rounding only, after the reader's own input) and LAYOUT_ALLOWANCE_PX (a composer growing a few lines before the correction to the end lands).
  • Screen-reader scrolling (3b) is not fixed and is documented beside LAYOUT_ALLOWANCE_PX. A scroll that sends no input this page can see, from a screen reader or from Firefox's scrollbar (which may send no press), leaves the end only past 48 px. The page has no signal to tell such a scroll from the composer's own growth, and guessing would bring back the composer-growth bug fix(chat): keep following the end while a growing draft shrinks the transcript #1431 fixed.

No visible change otherwise. Design precedent: none of the coding-agent apps exposes this. It follows the rule #1734 set: only input that scrolls the transcript up is the reader's intent.

#1831: that open PR edits the same allowance line in onScroll (it adds byReader and navigated()). Whichever lands second keeps both: #1831's byReader with the named constants. A held-thumb drag then also counts as navigation for #1831's follow of the reader's own send.

How it was verified

  • scripts/chat-virtual-browser-qa.ts (already in CI's browser lane) runs the real ChatView in Chromium at 1440 px and 390 px, light and dark. Its scroll-intent loop gains four cases. Each fires the scrollbar press on the scroller right of its content box, then moves the transcript up 20 px, inside the layout allowance but past endpoint rounding. performance.now is skewed by 600 ms around that move, so the move comes after the input window as a resting thumb's would, with no sleep.
    • scrollbar-held: the press is still held when the move lands. Following stops and New messages shows.
    • scrollbar-held-released-first: the thumb is released before the scroll event is delivered (the order Codex's review found). Following stops.
    • scrollbar-released, scrollbar-unseen-release (no pointerup, the next pointer event has no button down): the press is over, so the same 20 px correction keeps following.
  • On origin/main the script fails at scrollbar-held: only upward input stops following (0 !== 1). On this branch it passes, 52 PASS, together with the existing cases: a downward wheel, a click, typing and composing keys keep following; an upward wheel, navigation keys and a drag stop it; a composer growing a line paints no frame off the end.
  • bun run check fast passes on this commit (typecheck, build, unit tests).
  • Reviewed by a code-review agent (omo, approved) and by Codex (gpt-6.1-sol). Codex's one MEDIUM (a thumb released before the browser delivered its scroll lost the intent) is fixed and covered by scrollbar-held-released-first. omo's LOWs:
    • Fixed: the hold now needs the primary button, and it is cleared when the chat's input listeners go away (the pane hidden or left mid-press).
    • Left: a new press clearing the hold has no case of its own; it duplicates the release paths.

Not reproduced on a real Firefox or with a screen reader; see the screen-reader note above.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 23 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 2db8d37 and 2afc247.

Files selected for processing: 4
  • changelog.d/chat-scrollbar-held-intent.md
  • scripts/chat-local-send-follow-demo-regression.ts
  • scripts/chat-virtual-browser-qa.ts
  • src/components/ChatView.tsx
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Merge pass review of eae26cf. Under the maintainer's rule this was reviewed by a Codex Sol review, the omo code reviewer and the merge pass's QA. CodeRabbit was rate-limited.

Verdicts: the code reviewer passes it, with LOW notes only. Codex Sol fails it on two MEDIUM findings, below, that this PR introduces. Please fix those two.

1. A missed release can leave the hold set (src/components/ChatView.tsx:1560)

  • Nothing clears scrollbarHeld on window blur.
  • If the release is missed while the page stays visible, for example after a focus change, and the reader comes back by keyboard only, no pointer event clears the hold.
  • Codex replayed the handlers: the hold was still set after 600 ms. A 20 px programmatic upward correction then set pagingAway and turned following off. A later pointer event clears the hold but doesn't turn following back on.
  • Fix: clear the hold on blur and on visibilitychange to hidden.

2. While the thumb is held still, programmatic upward corrections count as the reader's (ChatView.tsx:1574, 1583)

  • While held, every upward scroll refreshes reader intent, whatever caused it.
  • Replaying pointerdown → 600 ms → scrollTop -= 20 → scroll: following stays on with main's code and turns off with this head.
  • This drops the layout allowance exactly while a thumb rests. Because the pending follow correction is cancelled, the wrong state outlasts the release.
  • Fix: count an upward scroll as the reader's only if it didn't come from a scroll this component started, using the same marker the follow allowances already use. Please add that case to the browser QA.

3. Not caused by this PR (LOW, follow-up): overlay scrollbars sit inside clientWidth, so the x >= clientWidth hit test never arms the hold. Chat then keeps main's 48 px behaviour, as the PR says. The QA script places the press coordinate itself, so it can't show this case.

Holds

  • The QA script fails without the fix: the code reviewer put back the parent ChatView.tsx and got exit 1 at scrollbar-held: only upward input stops following.
  • All 8 listeners are removed on unmount.
  • A press on the content never arms the hold.
  • typecheck and conventions pass.

The merge pass re-checks the fix commit, looking only at the changed range.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Both MEDIUM findings are fixed in f8e1f6a. A held thumb's move is now decided when it is let go, not while it is held.

2. Upward corrections while the thumb rests. ChatView no longer treats an upward scroll during a held press as the reader's. While the thumb is held, every upward move keeps main's layout allowance (48 px), so a correction the layout makes under a resting thumb keeps following, as on main. The page sees no pointer events during a thumb drag, so the release tells the two apart:

  • let go away from where it was pressed: it was dragged. If the transcript moved up while it was held and is now past endpoint rounding, following stops there, through the same pagingAway path and onScroll as any reader scroll.
  • let go where it went down: it only rested, and nothing changes.
  • the earlier order Codex found (released before the scroll event is delivered) still counts from the release.

There is no separate "own scroll" marker to reuse. The component's own scrolls are downward (scrollToEnd) or explicit navigation, so the upward move under a resting thumb is the list/layout's, and the release position is what separates it from a drag.

1. A release this page never sees. The press is now cleared on window blur, on visibilitychange to hidden, on any pointer event with no button down, on a new press, and when the chat's listeners go away.

Evidence (scripts/chat-virtual-browser-qa.ts, 1440 and 390 px, light and dark; the thumb rests past the 500 ms input window through a skewed clock):

  • New cases: scrollbar-held-still (rests while a 20 px correction lands, let go in place: keeps following) and scrollbar-blurred (window blur while held, later let go away from the press: keeps following). scrollbar-held and scrollbar-held-released-first now let go away from the press and stop following.
  • The previous head eae26cf with this script fails at scrollbar-held-still: only upward input stops following, your replay. f8e1f6a passes all 52 cases (check report verified on a clean tree).
  • bun run check fast passes on f8e1f6a.

A mutant without the blur listener is queued, to show that scrollbar-blurred fails without it; I'll add the result here.

The overlay-scrollbar note (3) is unchanged: with overlay scrollbars the press is not seen as a scrollbar press, so Chat keeps main's 48 px behaviour there.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the mutant: f8e1f6a with only the window blur listener removed fails scripts/chat-virtual-browser-qa.ts at scrollbar-blurred: only upward input stops following. So that case does catch a missed release that stays held. The real head passes it.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Merge pass: this PR now conflicts with main in src/components/ChatView.tsx, after this batch (#1831, #1890, #1895 and #1897 merged). Please rebase onto main and push. The merge pass then rechecks only the rebase delta and requeues it.

mp added 3 commits October 25, 2026 05:26
… follow allowances

A scrollbar thumb held past the 500 ms input window and then dragged up less than 48 px kept
following: Chromium sends no pointermove while the thumb moves, so only the press was recorded.
While a primary press on the transcript's scrollbar is held, an upward move is the reader's own,
also when the thumb is released before the browser delivers the scroll. The 1 px and 48 px
allowances are named, and the screen-reader limit is explained beside them.

Fixes #1434
The merge pass found two ways the held-thumb rule from the previous commit misread the reader: an upward layout correction while the thumb only rested counted as the reader's, and a press whose release went unseen (a focus change, the page hidden) stayed held. While the thumb is held, upward moves now keep the layout allowance; when it is let go away from where it was pressed (a drag), a transcript that moved up stops following then. Blur and the page going hidden clear the press.
@devswha
devswha force-pushed the fix/chat-scroll-focus-followups branch from f8e1f6a to e765586 Compare October 24, 2026 20:30
@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main after the batch (#1831, #1890, #1895, #1897, #1830) and pushed e765586 (was f8e1f6a).

Conflict: one, in ChatView's onScroll. #1831 split the allowance into byReader and added if (scrollingUp && byReader) navigated();, and this PR named the allowances and added the held-scrollbar rule. Both are kept:

const byReader = performance.now() - scrollInputAt.current < SCROLL_INPUT_MS;
const allowance = byReader ? READER_ALLOWANCE_PX : LAYOUT_ALLOWANCE_PX;
if (scrollingUp && byReader) navigated();

READER_ALLOWANCE_PX is 1 and LAYOUT_ALLOWANCE_PX is 48, so main's behaviour is unchanged.

One integration line, as its own commit e765586: the let-go branch for a dragged thumb (from e87c195) stops following through pagingAway. Without a navigated() call there, #1831's pending local send would still have taken the reader to the end after a thumb drag. That branch now calls navigated(), the same as an upward wheel or a paging key. Please check it as part of the delta.

Verified on e765586: bun run check fast, scripts/chat-virtual-browser-qa.ts, and scripts/chat-local-send-follow-demo-regression.ts all pass. Each ran through the shared heavy queue before the push.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Merge pass review of e765586 only (Codex gpt-6.1-sol xhigh, and an omo code reviewer, independently): the fix is partial; please extend it and add a test. The rebase part (f8e1f6a → e87c195) is fine.

P2 / MEDIUM (both reviewers): a larger delayed drag still lets a pending send override navigation (ChatView.tsx:1559, :1555, :1632).

  • The new navigated() runs only while stickToBottom.current is still true. When the thumb rests more than 500 ms and is then dragged more than 48 px up, onScroll turns following off first, without calling navigated(). The release branch is then skipped.
  • The navigation generation stays the same, so the pending receipt pins the chat back to the end at :1680.
  • Codex replayed the production handler: gaps of 49 px and 200 px became 0 (pulled back to the end). A 20 px gap correctly stayed away.
  • The commit covers only drags that end 1–48 px from the end. The gap was already on main after fix(chat): follow your own accepted send back to the end unless you navigate #1831, so this isn't a regression, but it's the case this commit sets out to fix.
  • Fix: call navigated() on any dragged release that moved up and ends more than 1 px from the end, not only while following is still on. Or call it from onScroll where a held-thumb move turns following off.

P3 / MEDIUM: no regression coverage.

  • scripts/chat-virtual-fixture.tsx:35 gives no localSend.
  • scripts/chat-local-send-follow-demo-regression.ts:282 navigates only with the wheel.
  • Please add a step there with a pending receipt and a held thumb. Cover small and large delayed drags, with the scroll delivered both before and after release.

What holds: the call's placement on the branch it does reach. No double counting, no cancellation from stationary or layout-only scrolls or from moves of 1 px or less, and no regression for a drag that returns to the bottom. 24 + 42 related unit tests pass.

The merge pass rechecks the next commit (changed range only) and queues it.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Both points are fixed in 2afc247.

P2, a larger delayed drag: a dragged thumb that moved up now counts as navigation however far it went, in both release paths (ChatView.tsx, the pointerup branch):

  • If the drag's scroll is still on its way when the thumb is let go, navigated() runs there, next to the scrollInputAt update.
  • If the scroll was already shown (press.movedUp) and the thumb ends more than 1 px from the end, navigated() runs whether or not the layout allowance already stopped following. The pagingAway stop still runs only while following is on, as before.

A let-go where the thumb went down (it only rested), and a drag that returns to the end, call nothing.

P3, coverage: scripts/chat-local-send-follow-demo-regression.ts has a new step 2b. A pending receipt is held, the thumb is pressed, it rests past the 500 ms input window, and it is dragged up. It runs four times: 30 px and 200 px, each with the drag's scroll delivered before and after release. The step asserts that the released receipt does not take the reader back to the end.

  • Old code (e765586): the 30 px cases pass, and the 200 px case fails with gap 214 -> 0, your reproduction.
  • New code: all four pass.

Gate on 2afc247: the follow regression passes with all four new cases, as do bun run check fast and scripts/chat-virtual-browser-qa.ts. Each ran through the shared heavy queue before the push.

@devswha

devswha commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

Merge pass review-work report. Overall: PASSED. Under the maintainer's rule this ordinary client change was reviewed by a Codex Sol review, the omo code reviewer and the merge pass's QA. CodeRabbit was rate-limited.

Lane Verdict
Code review of eae26cf (omo-native-code-reviewer: programming, remove-ai-slops, frontend) PASS, LOW notes only
Codex Sol (gpt-6.1-sol, fast, xhigh) of eae26cf FAIL: two MEDIUM findings
Codex Sol re-check of f8e1f6a (the fix) PASS

The fix in f8e1f6a

  • A held press on the scrollbar now records where it went down and whether the transcript moved up.
  • The move is judged when the press is let go. A thumb let go away from where it went down was dragged, and stops following. One let go where it went down only rested while the layout moved, and keeps following.
  • So a programmatic upward correction while the thumb rests no longer counts as the reader's. This was Codex finding 2.
  • blur and visibilitychange to hidden clear the press. This was Codex finding 1.

Checked

  • Codex replayed both earlier sequences on the fix and found both fixed.
  • The QA script fails without the fix. The code reviewer reproduced this by putting back the parent ChatView.tsx, which exits 1 at scrollbar-held: only upward input stops following.
  • All listeners are removed on unmount.
  • typecheck and conventions pass. The browser QA runs on CI.

Follow-up (LOW): overlay scrollbars sit inside clientWidth, so they never arm the hold, and Chat keeps main's 48 px behaviour. This was already the case before this PR.

All required checks are green and no review thread is open.

  • After the rebase onto fix(chat): follow your own accepted send back to the end unless you navigate #1831 (e87c195, e765586, 2afc247), re-reviewed per range by Codex Sol and an omo code reviewer:
    • e765586: found partial. A thumb that rests over 500 ms and is then dragged up more than 48 px was still re-pinned by a pending local-send receipt, and no regression step covered it.
    • 2afc247: fixes both. navigated() now runs on any dragged release that moved up and ends more than 1 px from the end. pagingAway, dropPendingListScroll and onScrollRef stay guarded by following. A new step in chat-local-send-follow-demo-regression.ts holds the receipt and drags 30 px and 200 px, with the scroll arriving both before and after release.
    • Codex: LGTM. Replaying the handler, 20, 49 and 200 px gaps survive the receipt. Downward drags, drags that come back to the bottom, stationary presses and layout-only scrolls don't count as navigation.
    • Code reviewer: PASS. The browser regression passes all 8 steps at HEAD. With e765586's ChatView it fails at :334 (gap 214 → 0).
    • LOW follow-ups:
      • if (dragged) navigated() at :1557 is redundant in the usual event order.
      • The test's performance.now override isn't restored in a finally.

CodeRabbit did not review this PR (rate-limited or skipped). The reviews above stand in for it.

@devswha
devswha merged commit e1c329b into main Oct 24, 2026
11 checks passed
@devswha
devswha deleted the fix/chat-scroll-focus-followups branch October 24, 2026 22:23
devswha added a commit that referenced this pull request Oct 24, 2026
…#1916)

## What this fixes

`main` is red since #1840 merged: `Browser regression (other scripts)`
fails in `scripts/chat-local-send-follow-demo-regression.ts` at "remote
pending refused and hydrated sends preserve place: remote" (line 112,
`committed()` sees no transcript read carrying more within 10 s). It
fails on 5e673ef (#1840) and ff11d94; e1c329b (#1902), just before
#1840, passed. Every PR's CI inherits it.

## Why

The script plays "another browser" with a raw submit frame whose
`payload` is `text + "\r"`, the old shape. #1840 checks an immediate
submit's framing in the bridge and the demo transport: the payload must
be the text with LF as CR, bare or inside one paste pair, and the Enter
is the submit's own (`src/lib/compose.ts`: "The text a composer message
types, without its submit (HerdrSocket.submit adds the Enter)"). The
demo transport therefore refuses the frame with `invalid_submit_text`,
the remote message never reaches the transcript, and the step times out.
The transport is right; the script's frame is stale.

## The change

One line, test-only: the other browser's frame sends `payload: text`, as
the composer shapes it. No product code changes.

## Verification

- On main (ff11d94), unchanged, the script fails at "remote pending
refused and hydrated sends preserve place: remote", as in CI.
- With this line it passes, including that step's queued, pane-switch
and reconnect parts.
- `bun run check fast` passes.

Test-only, so it doesn't need a CodeRabbit review.

Co-authored-by: mp <mp@local>
devswha added a commit that referenced this pull request Oct 28, 2026
## What it does for the user

Chat now keeps following new output to the end when part of the
transcript folds on its own. Before
this, a turn settling at the end (its work folding together with a block
above it), or a block above
the view folding while the view was still gliding to the end, could stop
following mid-transcript and
show **New messages**, with no scroll by the reader.

Fixes #2042. Reported by @odfalik.

## Cause

`onScroll` in `src/components/ChatView.tsx` treats an upward move with
no reader input that lands past
the 48 px layout allowance as the reader leaving the end: it sets
`pagingAway` and drops the list's
pending scroll to the end. LegendList (`maintainVisibleContentPosition`)
and the browser make such
moves themselves when the transcript shrinks. The list holds the rows on
screen where they were, and
the browser keeps the view inside a shorter transcript. For one shrink
both can move the view up, so
it lands hundreds of pixels from the end, or past the end of a follow
glide that was still on its way.

## Change

- Without reader input, an upward move during which the transcript got
shorter (the last row moved
up in it, or its height dropped since the previous scroll event) now
counts as the layout's: following
goes on, and a view left more than 48 px short of the end is put back
there.
- That pin-back is skipped while `pagingAway` is set and while a
following reader's own click is laid
out where they are (ChatTimeline `manualLayout`), as the timeline's own
follow paths already do.
- Over a transcript that did not shrink, nothing changes: a no-input
scroll (a screen reader's, a
Firefox scrollbar's) leaves the end once the view is past 48 px from it,
however small its steps
(#1902). Reader input (wheel, keys, touch, a scrollbar drag) is
unchanged.
- The new scroll baselines reset with the chat's other scroll state on a
pane change or a history
  replacement.

Known trade-off: a no-input reader scroll that lands in the same scroll
event as a fold is put back at
the end once; the next move leaves as before. During a turn that keeps
folding this can happen more
than once. The page has no signal that tells such a scroll from the
layout's own move in that event.

Design precedent: none of the coding-agent apps exposes how it tells its
own re-layout from a reader's
scroll; this follows the rule #1734 set and #1902 kept, that only input
that scrolls the transcript up
is the reader's intent.

## How it was verified

- `scripts/chat-virtual-browser-qa.ts` (real `ChatView` in Chromium,
1440 and 390 px, light and dark,
  already in CI's browser lane) gains:
- `fold-together`: at the end, the last row and a row above fold in one
layout (a turn settling).
Following goes on to the end and the next output is followed. **Fails on
main**
    ("fold-together: the next output is followed"), passes here.
- `fold-above` / `grow-above`: a row above folds or grows while the view
glides to the end after the
last row grew. These pass here; on main they fail only when the fold
lands in the same frame as the
    glide, so they do not fail on every main run.
- `wheel-up`: a real upward wheel (`page.mouse.wheel`) still stops
following ("Jump to latest", then
    **New messages** with the reader left away from the end).
- Two 30 px upward steps without input from the end stop following past
the allowance. This catches
    the first commit's regression found in review.
  - Full script: 60 PASS over all four width/theme combinations, exit 0.
- `scripts/chat-local-send-follow-demo-regression.ts` (#1831),
`scripts/chat-gjc-streaming-browser-qa.ts`
(#1388/#1937) and `scripts/composer-fit-demo-regression.ts` (#1431):
pass on the final commit.
- `bun run check fast`: green on the final commit.
- Reviews: Codex (gpt-6.1-sol, fast, xhigh) and the omo code reviewer.
Both blocked the first commit on
one finding: the 48 px allowance applied per scroll event, so small
no-input steps were pinned back
(a #1902 regression). The second commit measures it from the end again.
The omo reviewer's other
findings were fixed: the `manualLayout` guard and the baseline resets.
Codex re-reviewed the fix:
  APPROVE.

Not reproduced on a real Firefox or with a screen reader. Once, during
QA, a real wheel landing early in
a glide did not stop following; it did not reproduce in six later runs
(three on main, three here) and
this change does not touch moves made with reader input.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat scroll intent: record only upward scroll input, and keyboard scrolling (from #1431)

1 participant