Skip to content

fix(chat): keep the words under a selection held through a slow fold - #2684

Merged
devswha merged 8 commits into
mainfrom
fix/flaky-chat-selection-turn-end
Nov 4, 2026
Merged

devswha merged 8 commits into
mainfrom
fix/flaky-chat-selection-turn-end

Conversation

@devswha

@devswha devswha commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Change

scripts/chat-selection-turn-end-demo-regression.ts (#2658) failed intermittently in "Browser regression (other scripts)" on PRs that do not touch chat, e.g. #2621 run 41725988136: desktop-1280-fold, one frame at 533.3 px against 572.8. It is not the test: the selection hold from #2658 loses the words under the selection when frames are slow. Instrumenting every frame (rAF, every ResizeObserver callback, every scrollTop set and scrollBy, the scroll events) on a slow machine showed three races, all in the hold:

  1. A fold in the held words' own row was painted uncorrected for a frame. LegendList puts a row's shrink of more than 1 px off to the next animation frame (scheduleWebShrinkMeasurement), so when the ending turn's work block folds above the words in the same row, no observer the hold listens to fires in that frame. On a slow frame the slide moves 40-50 px at once, and that frame is painted with the words moved. The hold now also watches the held words' own row ([data-chat-row]) with the chat's resize observer, which the chat made when it mounted, so the move is put back in the frame it happens, before the paint.
  2. The hold's own correction made the chat think the reader left the end. keep() set a fractional scrollTop (253.5625), reported the read-back (254) through onFollowed, and the scroll event then read 253: a 1 px move up with no input, which ChatView.onScroll took for the reader leaving the end. following went false, keep() stopped holding, and the rest of the fold drifted the words 10-27 px. keep() now scrolls in whole pixels, and keeps the words' aimed place as its reference instead of re-reading it, so the rounding adds up with the next move instead of drifting by a pixel.
  3. The list and the browser move the view up after the hold, with no input. A frame later LegendList measures the same fold and its MVCP scrollBys again (−24, −36 px), and the browser trims the view as the transcript shrinks. ChatView.onScroll now takes a move up without the reader's input as the layout's while a selection holds the view (the reader's wheel, keys, touch and scrollbar still count as leaving), and the hold hears the list's adjust (listen("scrollAdjust")) and puts the words' whole move back, as it already did for the list's padded frame (listEndPadded).

scrollAdjust is a signal LegendList's listen already delivers, but its public LegendListListenerType leaves it out; the existing patches/@legendapp%2Flist@3.3.5.patch gains a one-line react.d.ts hunk adding it (no runtime change, bun.lock unchanged; checked with a clean install from an empty cache).

The test: readings are compared unrounded. Rounding each to 0.1 px first made a 0.96 px step (inside the hold's own sub-pixel rule, put back the next frame) read as exactly 1 px and fail < 1. And a new case, desktop-1280-fold-reduced-motion: with reduced motion the block folds in one frame, which is what one slow frame of the slide does, so it fails on main every time instead of only on a slow runner (on main the words move to 608.8 and then 460.8 px; reduced-motion users saw that).

Review round (1db6fa9): only a move up of at most one pixel without input is excused while a selection stands (the browser's whole-pixel trim), only a move of at most a pixel landing within a pixel above where the chat itself last put the view (a4e9fa8, 8181b42), so a reader who scrolls up with no input the page sees, in large steps or a pixel at a time, is still away from the end once the selection goes; the script gains desktop-1280-reader-away for it (fails 3/3 on c9901b9), and watches each fold until it has slid shut. Details in the review comment.

No changelog fragment: changelog.d/chat-selection-holds-still.md from #2658 is still unreleased, and this makes it true.

Validation

Before/after on this PC (load average 36-98 from other lanes and CI runners), the demo build as CI builds it (bun scripts/demo-build.ts), through dori heavy:

origin/main 4bc043d this PR
chat-selection-turn-end-demo-regression.ts, whole script 0/20 pass (old script); 0/5 with the new script, the reduced-motion case failing every time it is reached 24/24 pass, 6 in parallel
desktop-1280-fold only, looped (instrumented copy) fails within the first 3 iterations 150/150 (6 × 25 in parallel)
phone-390-fold only, looped 25/25
desktop-1280-fold with reduced motion, looped 0/5 10/10

Intermediate states, for the record: with only the row watch (1), 18/20 whole-script runs passed and the remaining failures were the drift of (2) and (3); with (1) and (2) but no list-adjust signal, transitions passed 150/150 but reduced motion still failed.

  • bun run check fast (through dori heavy) on c9901b9 and again on 1db6fa9: workflow syntax, generated types and themes, typecheck, build, unit tests (10883 pass) all ok.
  • On 1db6fa9, under the same load: whole script (then 7 cases) 24/24; desktop-1280-fold looped 150/150, phone-390-fold 25/25, reduced motion 10/10.
  • On a4e9fa8 (last review round: only a landing a pixel above where the chat last put the view is excused), whole script (10 cases: reader-away in 30 px steps, 1 px steps, 1 px after growth below, and down-then-up) 24/24; the same loops 150/150, 25/25, 10/10; the 11 adjacent scripts pass; check fast ok. Each new reader-away case fails on the commit before its fix (1db6fa9, c358899, f37fab8). On the merged head 0830c48: check fast ok (10894 unit tests), whole script 6/6. On 8181b42 (the event's own move must be a pixel at most too; new 151 px case, which fails 2/2 on 0830c48): whole script (11 cases) 6/6, check fast ok.
  • Adjacent browser scripts on the final build, all pass: chat-virtual-browser-qa.ts, chat-history-browser-qa.ts, chat-local-send-follow-demo-regression.ts, chat-quote-selection-end-regression.ts, chat-scroll-padding-regression.ts, chat-select-quote-demo-regression.ts, chat-selection-copy-regression.ts, chat-working-line-demo-regression.ts, chat-no-rewind-demo-regression.ts, reply-comments-demo-regression.ts, chat-gjc-streaming-browser-qa.ts.
  • chat-browser-qa.ts could not run here: it times out waiting for .conn-live on this PC, the same on origin/main (no live connection in this environment); CI runs it.
  • chat-virtual-browser-qa.ts's failure on feat(server): support IPv6 loopback beside IPv4 #2644 (continuous content left the reader behind for 6 consecutive animation frames, line 717) is a different cause: it is about following streamed content, and that run (2026-11-03 08:03 UTC) predates fix(chat): hold the chat still while a selection is in progress (#2650) #2658. Not addressed here.
  • Not run: bun run check full, the Mac and Windows lanes. No server, shared or install code changed.

The selection hold from #2658 lost the words when frames were slow, which
made chat-selection-turn-end-demo-regression.ts flaky in CI:

- the list puts a row's shrink off a frame, so a fold in the held words'
  own row was painted uncorrected: the hold now watches that row too;
- a fractional scrollTop read back a pixel apart looked like the reader
  leaving the end, and the hold stopped: it scrolls in whole pixels and
  keeps the words' aimed place;
- the list's MVCP scrollBy and the browser's trim after it moved the view
  up with no input: under a hold that is the layout's, and the hold puts
  the list's adjust back (scrollAdjust, typed in the LegendList patch).

The script compares unrounded readings and adds a reduced-motion fold,
which fails on main every time.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Nov 3, 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 6 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 c5f5331 and 2a03a03.

Files selected for processing: 4
  • patches/@legendapp%2Flist@3.3.5.patch
  • scripts/chat-selection-turn-end-demo-regression.ts
  • src/components/ChatTimeline.tsx
  • 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

…der's scroll

Review (Codex, reproduced by omo): every move up without input under a
selection was taken for the layout's, so a reader who scrolled up with no
input the page sees (a screen reader, find in page) was pulled back to the
end once the selection went. Only the browser's whole-pixel trim needs the
excuse; anything more is the reader leaving the end again.

The turn-end script gains that case, and watches each fold until it has
slid shut and the list has measured it.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@devswha

devswha commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner Author

Review round on c9901b9 (omo: BLOCK; Codex gpt-6.1-terra xhigh: REQUEST_CHANGES, no HIGH). Fixed in 1db6fa9:

  • MEDIUM, reproduced (Codex; omo reproduced it): ChatView.onScroll excused every move up without input while a selection stood, so a reader who scrolled up with no input the page sees (a screen reader, find in page) was pulled back to the end once the selection went. Now only a move up of at most one pixel is excused under a selection: the browser's whole-pixel trim as the transcript shrinks, which is all the hold needed. The turn-end script gains desktop-1280-reader-away (selection stands, three 30 px scrolls with no input, selection cleared, the reader must stay 90 px away): it fails 3/3 on c9901b9 and passes on 1db6fa9.
  • LOW (Codex), test only: the script now watches each fold until no .animated-height.is-moving remains, plus the frames the list measures it in, instead of stopping at a fixed point.
  • MEDIUM, not reproduced (Codex): a scripted scroll landing in the same frame as the list's scrollAdjust would be put back for that frame along with the list's move. It is the same one-frame trade-off fix(chat): hold the chat still while a selection is in progress (#2650) #2658 accepted for the list's padded frame (listEndPadded), and LegendList's adjust delta is not exposed to separate them. Left as is; no follow-up issue since it was not reproduced.
  • LOW (omo), not reproduced: the scrollAdjust subscription could be missing if listRef.current were null when the effect runs. The list's imperative ref is attached in the commit's layout phase, before passive effects run, so it is set; and a replaced list would also leave the existing resize observer on the old node (same pattern as before this PR). Left as is.

Validation on 1db6fa9: bun run check fast ok (10883 unit tests). Under load (6 in parallel, load average ~60): whole script 24/24; desktop-1280-fold looped 150/150, phone-390-fold 25/25, reduced motion 10/10. The 11 adjacent chat browser scripts listed in the PR body pass.

CodeRabbit is rate-limited on this PR (no review ran); the omo and Codex reviews above stand in for it.

…t allowance

Review (Codex, reproduced by omo): each move up of a pixel without input
was excused under a selection however many came, so a reader who left the
end a pixel at a time was pulled back once the selection went. The moves
excused under one selection now add up to the layout allowance at most;
past it they are the reader's, as without a selection.

The reader-away case runs in 30 px steps and in 1 px steps.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@devswha

devswha commented Nov 3, 2026

Copy link
Copy Markdown
Owner Author

Second review round on 1db6fa9 (Codex gpt-6.1-terra xhigh: one MEDIUM, no HIGH; omo: BLOCK on the same MEDIUM, reproduced). Fixed in c358899:

  • MEDIUM, reproduced: each move up of at most one pixel without input was excused under a selection however many came, so a reader who left the end a pixel at a time (90 × 1 px) was pulled back once the selection went. The moves excused under one selection now add up to the layout allowance (48 px) at most, reset when the selection goes; past it they are the reader's, as without a selection. desktop-1280-reader-away now runs in 30 px and in 1 px steps: the 1 px case fails 3/3 on 1db6fa9 and passes on c358899.
  • omo's two LOWs (the adjusted flag taking a same-frame reader scroll back with the list's adjust; the listener bound to the list instance once) are the ones already answered in the previous comment; not reproduced, left as is.

Validation on c358899: bun run check fast ok (10883 unit tests). Under load (6 in parallel): whole script (now 8 cases) 24/24; desktop-1280-fold looped 150/150, phone-390-fold 25/25, reduced motion 10/10; the 11 adjacent chat browser scripts in the PR body all pass. CodeRabbit is still rate-limited on this PR.

devswha and others added 3 commits November 4, 2026 08:59
…view

Review (Codex, reproduced by omo): a budget of excused pixel moves still
let a reader already far from the end (the transcript grew below the
selection) drift up a pixel at a time and be pulled back once the
selection went. Under a selection, only a move to within a pixel of where
the chat itself last put the view (a follow, or the hold's correction:
onFollowed) is now the browser's trim; any further it is the reader's.

The reader-away case also runs after the transcript grew below the
selection, a pixel at a time.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
… view

Review (Codex): the excused trim was bounded on one side only, so after
the reader scrolled down past where the chat last put the view, each move
back up was excused until it passed that place again. Only a landing
within a pixel above that place is the browser's trim now.

The reader-away case also scrolls down into what grew below the selection
after the hold has put the view back once, then up a pixel at a time.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@devswha

devswha commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Third (last) review round, on c358899 (omo BLOCK and Codex REQUEST_CHANGES on the same reproduced MEDIUM, no HIGH), then a Codex finding on the local fix; head is now 0830c48 (a4e9fa8 plus a merge of main after #2693):

  • MEDIUM, reproduced (omo): a 48 px budget of excused one-pixel moves still let a reader already far from the end (the transcript grew below the selection) drift up and be pulled back. The heuristic is gone: under a selection, only a landing within a pixel above where the chat itself last put the view (onFollowed: a follow, or the hold's correction) counts as the browser's trim (f37fab8). New case desktop-1280-reader-away-1px-grown-150: fails 3/3 on c358899, passes now.
  • Codex on f37fab8 (rated P1, not pushed): that bound was one-sided, so after the reader scrolled down past the chat's last placement every move back up was excused. Now 0 < placed - scrollTop <= 1 (a4e9fa8). New case desktop-1280-reader-away-1px-grown-300-down-200 (the hold first puts the view back for a row growing above the words, then the reader scrolls down into what grew and back up a pixel at a time): fails 2/2 on f37fab8, passes now. Codex re-review of a4e9fa8: APPROVE, no findings.
  • The earlier carry-over MEDIUM (a 48 px budget reaching into the next selection) is moot: the budget no longer exists.

Validation on a4e9fa8, under load (6 in parallel): whole script (now 10 cases) 24/24; desktop-1280-fold looped 150/150, phone-390-fold 25/25, reduced motion 10/10; the 11 adjacent chat browser scripts pass; bun run check fast ok. On the merged head 0830c48: check fast ok (10894 unit tests) and the whole script 6/6.

chat-virtual-browser-qa.ts:81 (seen on #2621/#2644) is a different cause and is not addressed here: no selection is involved; after page.reload() a reopened chat can end about 54 px short of its end (CI's screenshot shows Jump to latest). It reproduces on main about once in a few hundred reloads on this PC; it goes to a separate PR.

@devswha

devswha commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Review of 0830c48: omo BLOCK on one reproduced MEDIUM, no HIGH (its Codex Terra pass reported the same one; Codex's own pass on a4e9fa8 approved). Per the last-round rule for this PR, it is not fixed here:

CI on 0830c48 is running.

Review (omo, reproduced): the trim excuse checked only where the view
landed, so a reader's no-input scroll of any size landing a pixel above
where the chat last put the view was excused, and the reader was pulled
back to the end once the selection went. The scroll event's own move up
must now be a pixel at most too (read before onScroll records the new
position).

The reader-away case adds a 151 px move landing exactly there.

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@devswha

devswha commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Head 8181b42: the fix for the MEDIUM omo reproduced on 0830c48 (a no-input scroll of any size landing a pixel above where the chat last put the view was excused). The scroll event's own move up must now be at most a pixel too, read before onScroll records the new position. (Comparing after that point, as first suggested, compares the position with itself; a debug build showed it.) The new case desktop-1280-reader-away-151px-grown-300-down-150 fails 2/2 on 0830c48 and passes on 8181b42. #2704 (filed for that MEDIUM) is resolved by this commit.

Validation on 8181b42: the whole script (11 cases) 6/6 under load; bun run check fast ok (10894 unit tests).

omo review at 8181b42: CLEAR (with a Codex Terra pass folded in). Remaining notes, none blocking:

  • LOW, reproduced: under a selection, after the hold has corrected the view, a single one-pixel no-input move up is still excused as the browser's trim even when nothing shrank, so a reader who leaves the end by exactly one pixel that way is brought back once the selection goes. Wheel, keys, touch and the scrollbar count as the reader, so this needs assistive tech moving exactly one pixel. Not filed as an issue (LOW); a possible tightening is to also require the transcript to have shrunk in that event.
  • LOW, not reproduced: the scrollAdjust subscription is made once from listRef.current; the list's imperative ref is attached before the effect runs, so it is there today.

CodeRabbit is rate-limited on this PR; the omo and Codex reviews stand in for it.

@devswha

devswha commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Merge note: CodeRabbit was rate-limited for these heads; merged on the omo review (CLEAR receipt at the head), Codex Terra (APPROVE; every reproduced MEDIUM across 4 rounds fixed with a failing-first case) and green required checks on GitHub-hosted runners. Remaining LOW notes are in the comments above.

@devswha
devswha merged commit e96b369 into main Nov 4, 2026
15 checks passed
@devswha
devswha deleted the fix/flaky-chat-selection-turn-end branch November 4, 2026 02:10
devswha added a commit that referenced this pull request Nov 4, 2026
…d chat lands at its end (#2720)

## Change

`scripts/chat-virtual-browser-qa.ts:81` (`settleBottom` after
`page.reload()`) timed out intermittently on PR runners (#2621's rerun,
twice in a row; #2644): the reopened chat ended about 54 px short of its
end, and CI's failure screenshot shows Jump to latest. The test was
right: a reopened chat took the reader for one who had scrolled away,
with no input.

Caught with a debug build that logs every follow and scroll decision (it
failed the first reload in 4 of 8 runs under load):

1. On the reload the list's first landing is short of the end (its
footer not measured yet), and the chat's `followMeasured` puts the view
at the real end: `max(measured 120061, scrollHeight 120115) - 900 =
119215`.
2. In the same frame the view is pulled up to **119161 = the list's own
measured `contentLength` (120061) - clientHeight**. LegendList's own
scroll to the end is still on its way, and while one is
(`state.scrollingTo`), its `onScroll` clamps any position past the end
*it has measured so far* back to that end (`clampScrollOffset`). Its
measure lags the transcript by 54 px there (the footer and some rows not
measured yet).
3. `ChatView.onScroll` sees a move up with no input, 54 px from the end
(more than the 48 px layout allowance) over a transcript that did not
shrink, and concludes the reader left the end: `stick=false
paging=true`, Jump to latest. Nothing follows again. When the lag is
under the allowance, the chat instead stays "following" short of the
end, with nothing left to correct it (the local variant).

LegendList's end on the web can only lag what the browser shows (it
measures what it renders, a frame or more late), so the fix is in the
LegendList patch this repo already keeps
(`patches/@LegendApp%2Flist@3.3.5.patch`, two hunks, plus a helper):

- A helper finds the end of what is drawn: the bottom of the list
content's children and of the rows inside the rows' wrapper (rows as
tall as they are drawn, a header or footer not measured yet), relative
to the scroll origin. It deliberately leaves out the room the list pads
past its end for a frame while it holds rows in place, and a
`min-height` under the content (the selection hold's room, #2684). An
earlier version used the scroller's `scrollHeight`, which includes that
room, and made `reply-comments-demo-regression.ts` drag selections land
on other words in 2 of 7 runs.
- `clampScrollOffset` never clamps below that drawn end. This is what
pulled the view up in the captured failure.
- `scrollToEnd` adds what the list has not measured yet (the drawn end
past its measured one) to its end offset, so its own target is the real
end instead of re-landing short.

When the list's measure matches what is drawn (settled), both changes
are no-ops; a larger measure (rows estimated taller than drawn) keeps
the list's own value. No app code changes.

The test: `chat-virtual-browser-qa.ts` gains the case right after the
reload step that timed out. A 100 px box drawn after the footer inside
the list's content, which the list never measures, makes its end lag the
transcript's by more than the layout allowance (as an unmeasured footer
and rows did in the captured failure), and a view a pixel shorter makes
the chat follow its end and the list scroll to its own. The chat must
end at the end, still following, with no Jump to latest. On main it ends
61 px short in every frame from the second on
(`{"gaps":[101,100,61,61,...]}`); with this change it ends at 0.

## Validation

- `chat-virtual-browser-qa.ts` with the new case: origin/main (with
#2684) **fails 2/2** at the new case (61 px short in every frame from
the second on); this branch **passes 4/4** (all four width/theme
passes). The case lags the list's end twice: a box after the footer, and
a box past the rows' wrapper (a row drawn taller than measured), which
the review head 735a121 failed 2/2.
- `reply-comments-demo-regression.ts` (drag selections, which the
earlier `scrollHeight` version broke): 10/10 on main; on this branch
10/10, and 6/6 again on the final head 74b4389 (with turn-end 4/4,
chat-history and local-send-follow passing, `check fast` ok).
- The original reload timeout reproduces only on a loaded machine (about
once in a few hundred reloads on this PC, 2 of 2 attempts on #2621's
runner), so its before/after rests on the captured trace above and the
deterministic case.
- Merged with main (#2684's selection hold also leans on the list's
scroll): `chat-selection-turn-end-demo-regression.ts` 6/6 on this
branch.
- Adjacent browser scripts on this branch, all pass:
`chat-history-browser-qa.ts`,
`chat-local-send-follow-demo-regression.ts`,
`chat-quote-selection-end-regression.ts`,
`chat-scroll-padding-regression.ts`,
`chat-select-quote-demo-regression.ts`,
`chat-selection-copy-regression.ts`,
`chat-working-line-demo-regression.ts`,
`chat-no-rewind-demo-regression.ts`,
`reply-comments-demo-regression.ts`, `chat-gjc-streaming-browser-qa.ts`.
Runs were capped at 4 at a time with one shared demo build per variant.
- The patch applies with a clean `bun install` from an empty cache and
reproduces the tested file byte for byte; #2684's `react.d.ts` hunk is
kept; `bun.lock` is unchanged.
- `bun run check fast`.
- Not run: `bun run check full`, the Mac and Windows lanes (no platform
code changed).

Changelog: `changelog.d/chat-reopen-lands-at-end.md` (Fixed).

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
ricardofelixb added a commit to ricardofelixb/ovis that referenced this pull request Nov 4, 2026
* fix(qa): open the folded work run before claude-image clicks a row

A work block's calls fold into a run line, so the row the check clicks is not drawn until the run is opened (the same step #44 added to the other image checks).

* fix(qa): hide-pc waits for motion that ends, not an agent's wave

An agent friend's wave never ends, so waiting for every animation to stop timed out. A looping animation is no motion to wait for.

* fix(qa): terminal-autoswitch reads the lens the header shows

The header's lens button flips the lens with one tap (#49) and shows the one it goes to, so a peek's header shows the grid, and one tap on it ends the peek. The check no longer expects Chat pressed, and the second way of keeping the terminal is the same tap as the first.

* fix(qa): workspace-rows reads the tab strip's row surface

The strip's background is on .tab-strip-row now; .tab-strip itself is transparent.

* fix(qa): pane-mute fixture's roster tells each pane's last status

A roster carries each pane's status as the server's does, so a page that takes a roster again (a stream reopened as the tab is shown) finds the panes as the frames left them, instead of the demo's first statuses.

* fix(chat): keep the words under a selection held through a slow fold (devswha#2684)

## Change

`scripts/chat-selection-turn-end-demo-regression.ts` (devswha#2658) failed
intermittently in "Browser regression (other scripts)" on PRs that do
not touch chat, e.g. devswha#2621 run 41725988136: `desktop-1280-fold`, one
frame at 533.3 px against 572.8. It is not the test: the selection hold
from devswha#2658 loses the words under the selection when frames are slow.
Instrumenting every frame (rAF, every ResizeObserver callback, every
`scrollTop` set and `scrollBy`, the scroll events) on a slow machine
showed three races, all in the hold:

1. **A fold in the held words' own row was painted uncorrected for a
frame.** LegendList puts a row's shrink of more than 1 px off to the
next animation frame (`scheduleWebShrinkMeasurement`), so when the
ending turn's work block folds above the words in the same row, no
observer the hold listens to fires in that frame. On a slow frame the
slide moves 40-50 px at once, and that frame is painted with the words
moved. The hold now also watches the held words' own row
(`[data-chat-row]`) with the chat's resize observer, which the chat made
when it mounted, so the move is put back in the frame it happens, before
the paint.
2. **The hold's own correction made the chat think the reader left the
end.** `keep()` set a fractional `scrollTop` (253.5625), reported the
read-back (254) through `onFollowed`, and the scroll event then read
253: a 1 px move up with no input, which `ChatView.onScroll` took for
the reader leaving the end. `following` went false, `keep()` stopped
holding, and the rest of the fold drifted the words 10-27 px. `keep()`
now scrolls in whole pixels, and keeps the words' aimed place as its
reference instead of re-reading it, so the rounding adds up with the
next move instead of drifting by a pixel.
3. **The list and the browser move the view up after the hold, with no
input.** A frame later LegendList measures the same fold and its MVCP
`scrollBy`s again (−24, −36 px), and the browser trims the view as the
transcript shrinks. `ChatView.onScroll` now takes a move up without the
reader's input as the layout's while a selection holds the view (the
reader's wheel, keys, touch and scrollbar still count as leaving), and
the hold hears the list's adjust (`listen("scrollAdjust")`) and puts the
words' whole move back, as it already did for the list's padded frame
(`listEndPadded`).

`scrollAdjust` is a signal LegendList's `listen` already delivers, but
its public `LegendListListenerType` leaves it out; the existing
`patches/@LegendApp%2Flist@3.3.5.patch` gains a one-line `react.d.ts`
hunk adding it (no runtime change, `bun.lock` unchanged; checked with a
clean install from an empty cache).

The test: readings are compared unrounded. Rounding each to 0.1 px first
made a 0.96 px step (inside the hold's own sub-pixel rule, put back the
next frame) read as exactly 1 px and fail `< 1`. And a new case,
`desktop-1280-fold-reduced-motion`: with reduced motion the block folds
in one frame, which is what one slow frame of the slide does, so it
fails on main every time instead of only on a slow runner (on main the
words move to 608.8 and then 460.8 px; reduced-motion users saw that).

Review round (1db6fa9): only a move up of at most one pixel without
input is excused while a selection stands (the browser's whole-pixel
trim), only a move of at most a pixel landing within a pixel above where
the chat itself last put the view (a4e9fa8, 8181b42), so a reader who
scrolls up with no input the page sees, in large steps or a pixel at a
time, is still away from the end once the selection goes; the script
gains `desktop-1280-reader-away` for it (fails 3/3 on c9901b9), and
watches each fold until it has slid shut. Details in the review comment.

No changelog fragment: `changelog.d/chat-selection-holds-still.md` from
devswha#2658 is still unreleased, and this makes it true.

## Validation

Before/after on this PC (load average 36-98 from other lanes and CI
runners), the demo build as CI builds it (`bun scripts/demo-build.ts`),
through `dori heavy`:

| | origin/main 4bc043d | this PR |
| --- | --- | --- |
| `chat-selection-turn-end-demo-regression.ts`, whole script | 0/20 pass
(old script); 0/5 with the new script, the reduced-motion case failing
every time it is reached | 24/24 pass, 6 in parallel |
| `desktop-1280-fold` only, looped (instrumented copy) | fails within
the first 3 iterations | 150/150 (6 × 25 in parallel) |
| `phone-390-fold` only, looped | | 25/25 |
| `desktop-1280-fold` with reduced motion, looped | 0/5 | 10/10 |

Intermediate states, for the record: with only the row watch (1), 18/20
whole-script runs passed and the remaining failures were the drift of
(2) and (3); with (1) and (2) but no list-adjust signal, transitions
passed 150/150 but reduced motion still failed.

- `bun run check fast` (through `dori heavy`) on c9901b9 and again on
1db6fa9: workflow syntax, generated types and themes, typecheck, build,
unit tests (10883 pass) all ok.
- On 1db6fa9, under the same load: whole script (then 7 cases) 24/24;
`desktop-1280-fold` looped 150/150, `phone-390-fold` 25/25, reduced
motion 10/10.
- On a4e9fa8 (last review round: only a landing a pixel above where the
chat last put the view is excused), whole script (10 cases: reader-away
in 30 px steps, 1 px steps, 1 px after growth below, and down-then-up)
24/24; the same loops 150/150, 25/25, 10/10; the 11 adjacent scripts
pass; `check fast` ok. Each new reader-away case fails on the commit
before its fix (1db6fa9, c358899, f37fab8). On the merged head
0830c48: `check fast` ok (10894 unit tests), whole script 6/6. On
8181b42 (the event's own move must be a pixel at most too; new 151 px
case, which fails 2/2 on 0830c48): whole script (11 cases) 6/6, `check
fast` ok.
- Adjacent browser scripts on the final build, all pass:
`chat-virtual-browser-qa.ts`, `chat-history-browser-qa.ts`,
`chat-local-send-follow-demo-regression.ts`,
`chat-quote-selection-end-regression.ts`,
`chat-scroll-padding-regression.ts`,
`chat-select-quote-demo-regression.ts`,
`chat-selection-copy-regression.ts`,
`chat-working-line-demo-regression.ts`,
`chat-no-rewind-demo-regression.ts`,
`reply-comments-demo-regression.ts`, `chat-gjc-streaming-browser-qa.ts`.
- `chat-browser-qa.ts` could not run here: it times out waiting for
`.conn-live` on this PC, the same on origin/main (no live connection in
this environment); CI runs it.
- `chat-virtual-browser-qa.ts`'s failure on devswha#2644 (`continuous content
left the reader behind for 6 consecutive animation frames`, line 717) is
a different cause: it is about following streamed content, and that run
(2026-11-03 08:03 UTC) predates devswha#2658. Not addressed here.
- Not run: `bun run check full`, the Mac and Windows lanes. No server,
shared or install code changed.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
(cherry picked from commit e96b369)

* fix(qa): pane-mute waits for the sheet's focus and ignores a cancelled animation

CI runs the script with UI_EVIDENCE_DIR set, which adds the captures. In
them the phone's keyboard walk pressed ArrowDown before the opening sheet
had taken focus, so the arrows landed on the toggle behind it and the
focus ended on the sheet's first item: the check now waits for focus to be
inside the sheet first, as the desktop branch does for the menu.

capture() also awaited animation.finished bare, which rejects with an
AbortError when an animation is cancelled; the other settle helpers catch
that, and so does this one now.

---------

Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
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.

1 participant