Skip to content

fix(chat): follow measured rows without a competing glide - #2624

Merged
devswha merged 2 commits into
devswha:mainfrom
odfalik:fix/chat-follow-measured-end
Nov 2, 2026
Merged

devswha merged 2 commits into
devswha:mainfrom
odfalik:fix/chat-follow-measured-end

Conversation

@odfalik

@odfalik odfalik commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Change

While a chat row keeps growing, onItemSizeChanged starts a smooth DOM scroll before LegendList finishes measuring the list. That scroll can chase an outdated endpoint and leave the newest text behind.

Use LegendList's deferred, non-animated scrollToEnd for this callback instead. The existing following and manual-layout guards stay in place. Reader navigation and other follow animations are unchanged.

The browser regression appends 70 ordinary paragraphs to a rendered row, 110ms apart, without waiting for each change to settle. It checks the gap from the bottom, consecutive frames behind, and total frames behind. The older growth/folding check no longer requires an animation to exist when following reaches the end immediately.

Evidence

Before is upstream main 05c91332. After is this fix on the same base. Both demo builds use the same generic conversation and paragraph-growth sequence. These recordings exercise controlled content-size changes in the real chat UI. Provider and network streaming are outside their scope. All chats and workspace labels are fictional demo data.

Chromium viewport Main p95 bottom gap Fixed p95 bottom gap
ChatView QA fixture, 1440×900 119px 41px
ChatView QA fixture, 390×844 120px 96px
Full demo, 1440×900 119px 41px
Full demo, 390×844 226px 73px

The generic-growth QA fixture does not fail the phone bound on upstream main. The full demo has a different layout and shows more phone lag with the same growth sequence. Its measurements are listed separately. These 390px captures are desktop Chromium at a phone-sized viewport, not iOS Safari.

Desktop, 1440×900

Before:

main-1440.mp4

After:

fixed-1440.mp4

Phone-sized viewport, 390×844

Before:

main-390.mp4

After:

fixed-390.mp4

Validation

  • bun scripts/chat-virtual-browser-qa.ts: passed all four viewport/theme cases, including continuous growth, reader-away behavior, folding, selection, pagination and restoration.
  • Negative control: the same browser regression against upstream main fails the desktop continuous-growth bound at p95 119px. The fixed callback gives 41px.
  • Phone-only upstream fixture control: passed at p95 120px, within the existing phone bound.
  • bun test src/lib/followScroll.test.ts src/lib/chatRows.test.ts src/components/ChatTurn.test.ts src/conventions.test.ts scripts/conventions.test.ts: 58 passed, none failed.
  • bun run check fast: failed in the unit lane (10,426 passed, 24 failed, 264 skipped). Failures include Codex store, shell harness and bridge tests outside the changed files. One private-terminal sign-in test process stalled past its timeout and was stopped, leaving that file without a result. Generated types/themes, typecheck and production build passed. Workflow syntax was skipped on macOS. Local Node is 25 rather than CI's 22.
  • Full integration suite, the full browser lane, native Windows and real iOS checks were not run. No HTTP/WS contract, dependency or stored-format change.

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: devswha/ovis
  • Branch: main
  • File: .coderabbit.yaml
  • Commit: 05c9133
  • Layers: Repository YAML
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7ef13209-478e-454a-80c3-10678fcb6b43

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
Recent review info
Run configuration
  • Source mode: Auto
  • Repository: devswha/ovis
  • Branch: main
  • File: .coderabbit.yaml
  • Commit: 05c9133
  • Layers: Repository YAML
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ea0b4e70-e209-4599-8257-05807138d753
Commits

Reviewing files that changed from the base of the PR and between 05c9133 and d03b9b1.

Files selected for processing: 3
  • changelog.d/chat-follow-measured-end.md
  • scripts/chat-virtual-browser-qa.ts
  • src/components/ChatTimeline.tsx

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

Summary

Summary by CodeRabbit

  • Bug Fixes
    • Chat now stays at the latest message while new text is continuously generated, instead of competing with a smooth scroll animation. When the height of the final message changes, the view follows the end immediately, keeping the newest text visible as it appears. This improves the consistency of following active conversations, including when messages grow through repeated updates.

ChatTimeline now scrolls to the end without animation after an item-size change when following is active and manual layout is inactive. Browser QA updates the follow-glide test and adds a continuous-streaming test.

Changes: Chat end-follow behavior

Layer / File(s) Summary
Measured row growth and follow validation
src/components/ChatTimeline.tsx, scripts/chat-virtual-browser-qa.ts, changelog.d/chat-follow-measured-end.md
ChatTimeline uses non-animated end scrolling after item-size changes when its existing follow and manual-layout checks allow it. Browser QA checks row growth, upward-wheel behavior, and scroll lag during 70 streaming updates. The changelog records the change.

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix

Possibly related issues

Possibly related PRs

  • devswha/ovis#2564 — Both changes adjust Chat end-follow behavior during layout changes.
  • devswha/ovis#1937 — Both changes use the manualLayout and following state to govern Chat follow behavior.
  • devswha/ovis#2538 — Both changes prevent a competing scroll from interfering with following the current end.

Suggested reviewers: devswha

Merge Risk: Low · previous review d03b9

Adjusting an open control can pull the reader back to the bottom. The issue is narrow and recoverable.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title "fix(chat): follow measured rows without a competing glide" matches the main change. ChatTimeline now calls scrollToEnd with animated false after item-size changes while following, which rem…
Description check Passed The description follows the repository template. The Change section explains the problem (a smooth DOM scroll chasing an outdated endpoint during row growth), the resulting behavior, and the guards th…
Docstring Coverage Passed No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title "fix(chat): follow measured rows without a competing glide" matches the main change. ChatTimeline now calls scrollToEnd with animated false after item-size changes while following, which removes the competing smooth scroll. The title is specific, concise, and uses the conventional prefix. It does not cover the test changes, but that is expected.

Full details: Description check

Explanation

The description follows the repository template. The Change section explains the problem (a smooth DOM scroll chasing an outdated endpoint during row growth), the resulting behavior, and the guards that stay in place. The Validation section lists the commands run with their results, the failing unit lane, and the checks skipped. The template asks for a linked issue for features, but this is a fix. The description does not mention the changelog.d entry, which the template references, although the diff adds one. The description is complete and specific enough to review.

Full details: Docstring Coverage

Explanation

No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 2 files. (1 skipped: 1 unsupported.)

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • 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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

  • Fix comments: Fix CodeRabbit comments on this PR

Comment thread src/components/ChatTimeline.tsx Resolved
Closing a disclosure while following now also drops a list scroll queued before the click, so the deferred follow can't land after the fold. The continuous-content QA logs its lag frames instead of bounding them (main and this fix both land near two per change), comments describe the remaining call, and the changelog says following no longer glides.
@devswha

devswha commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @odfalik, nice fix; the before/after recordings made it easy to check. Merge-pass review (omo and Codex Terra xhigh): the change is right, the new regression fails on main's code (p95 gap 119 px) and passes here, and a reader still escapes following with an upward wheel while rows grow (more reliably than with the old glide). I pushed one small commit on top of your branch (no force):

  • Closing a disclosure while following set manualLayout but left a scrollToEnd the list had already queued, so the deferred follow could land after the fold. The click now also drops that pending scroll (as opening one already did through readOpened).
  • The lagFrames <= updates*2+2 bound measured 139-140 here and 140-151 on main against a limit of 142, so it separated nothing and could flake on a slower runner; it is now logged only. The p95 gap and the consecutive-lag-frames bound are what catch the bug.
  • Comments now describe the remaining call, and the changelog adds a "Changed" line: following no longer glides.

bun run check fast and bun scripts/chat-virtual-browser-qa.ts pass on the new head (desktop p95 41 px, phone 96 px). Not verified: the touch-scroll escape on a real phone (the synthetic gesture moved neither branch), which rests on the same reader-input path as the idle follow.

@odfalik

odfalik commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up commit. Dropping the queued scroll on fold covers the case CodeRabbit raised, and the logged lag frames are a fair read of the measurements. Looks good from my side.

@devswha

devswha commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @odfalik for the fix, the regression and the before/after recordings. While a chat row keeps growing, following now uses LegendList's deferred, non-animated scrollToEnd instead of a smooth DOM glide that raced its measurement: p95 gap from the bottom goes from 119 px to 41 px on desktop and 120/226 px to 96/73 px at 390 px (his measurements; the merge pass reproduced 41 px and 96 px on this head, and the new regression fails on main's code). Following no longer glides while a turn works; the changelog says so.

Merge-pass review: omo approved, Codex Terra xhigh approved the final head. A reader still escapes following with an upward wheel while rows grow (reproduced at both widths, more reliably than with the old glide). The merge pass added one commit: a click that folds a disclosure while following now drops a scroll the list had already queued, so the deferred follow can't land after the fold; and the QA logs instead of bounding its lag-frame count (both branches land near the bound, so it separated nothing).

Left as is (LOW / not reproduced): no browser test yet clicks a disclosure while a follow is queued; a non-animated snap could in theory land between an upward touch gesture's start and its first scroll event (main's idle follow already uses the same call; the synthetic touch gesture moved neither branch, so a real-phone check is still to do); the scrollend-settle effect and followGlides naming are now vestigial; the PR doesn't name the agent app whose follow behaviour it matches (src/AGENTS.md).

CodeRabbit's check shows skipping on this head; the merge pass and both reviewers reviewed it.

@devswha
devswha merged commit 9ef34f8 into devswha:main Nov 2, 2026
12 checks passed
devswha added a commit that referenced this pull request Nov 3, 2026
)

## Change

Four browser scripts failed now and then in "Browser regression (other
scripts)" on main (run 41558343287 on c0cdc8e, and later runs). The
take-over failure was a product race, fixed in #2630. This PR fixes the
other three, plus the relay-media failure seen on bef2be1. Test-only:
no product code changes.

### `chat-local-send-follow-demo-regression.ts:95`: "the reader moves
away"

`wheelUp` waited for each wheel notch to lower `scrollTop` by more than
20 px. A write trace on the page showed every notch moving the view 120
px. Then, as a row above the view was measured, LegendList's
`ScrollAdjust` called `scrollBy(+103)` to keep the reader's rows in
place, with `scrollHeight` growing by the same 103 px. That undid the
notch in `scrollTop`, but the distance from the end stayed exactly 600
px before and after. The reader had moved away; the wait could not see
it. The wait now measures the distance from the end, which that
adjustment leaves alone.

Not a #2624 regression. An A/B of the unmodified script, alternating
commits under `dori heavy`, gave passes/runs of: 05c9133 (before #2624)
5/7, 9ef34f8 (#2624) 5/6, main 2/6. The step fails before #2624 too;
#2624 changes when rows get measured, so it lands more often. No
`scrollToEnd` or follow write appears in the trace.

### `file-viewer-regression.ts`: "video not visible"

The CI failure came right after `setFileOpen("panel")` reloads the page:
`videoLink.click()` was a pointer click on a transcript link while the
reloaded chat followed to its end, so the click landed on nothing and no
popup opened (0 "locator resolved" lines on CI and locally). The
script's own header comment describes this trap, and its other steps
already use `activateInTranscript`. Under load, three more steps hit the
same trap: after a `goto` (big code), after the panel closes (the
extensionless and unknown-text loop, which keeps the link focused so the
focus-return assertion still holds), and after a reload and Back at 390
px. The click on the framed page's table-of-contents link is dispatched
to the link: the frame's own navigation is under test, not where the
panel lays the frame out.

### `files-prompt-regression.ts:71`: Escape left Files open after a pane
switch

Not reproduced in about 435 local attempts (15 per run, fresh pages, 6x
CPU throttle, load up to 92), and nothing found by reading the code. The
Escape check stays: it is the only place a script checks Escape closing
Files. When it fails now, the script writes what decided it to the log
and to `files-<page>-escape-diagnostics.json` in the browser-evidence
artifact: whether a window keydown probe saw the key, the focused
element, the modal and dialog stack, whether FilesDialog stood down
(file or diff over it, native modal), and any open picker. Tracked as
#2631.

### `relay-media-browser-qa.ts:44`: "Timed out: copied-link clipboard
event"

Seen inside `relay-e2e-regression.ts` on bef2be1 (run 41568570526). The
stubbed `writeText` always resolves, so the click never reached Copy
link. `copy.click({ force: true })` is aimed at where the button was,
under a frozen browser clock, while the viewer may still move it. The
click is now dispatched to the button itself; the clipboard listener is
still subscribed before it.

## Validation

All runs went through `dori heavy`; host load ranged from about 4 to 92
(70 CPU hogs for part of it).

- chat-local-send-follow: 22/22 consecutive.
- file-viewer: 20 consecutive and counting on the final script (`bun run
check run`, isolated herdr session). Earlier rounds under load caught
the other lost-click steps fixed here.
- files-prompt: 22/22 consecutive. A forced failure (key withheld) wrote
the diagnostics JSON and screenshot. The review's try/catch, which keeps
the original timeout as the reported error, came after those runs; it
runs only when the check fails.
- relay-media (fixed): 22/22 consecutive; the unmodified script passed
10/10 locally, so CI is the only evidence for that one.
- `bun run check fast`: success on 6069b0a. An earlier run caught a
regression in this branch: `ci-browser.test.ts` forbids `??` on evidence
dirs (#1554). Fixed with `||`.
- omo code reviewer: APPROVE, LOW notes only. Applied: the diagnostics
never replace the original error. Not changed: browser-other uploads
PNGs only on failure; the 390 px reopen dispatches directly because
`activateInTranscript` waits for a frame that section lacks; the
`wheelUp` callers assert the distance afterwards.
- Skipped: full check, Windows VM.

No changelog fragment: test-only.
@devswha

devswha commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thank you @odfalik for this change, and for the thorough evidence and recordings in the description. The lag you measured was real, and your new continuous-content check is what made the problem below visible.

We're reverting it for now in #2647, and an improved version will land on top of it.

On GitHub-hosted runners, which fork PRs use and whose frames are slower than our own machine's, scripts/chat-virtual-browser-qa.ts:717 failed on every attempt: #2616 twice (4 consecutive frames behind), and #2636 (6). The follow through LegendList's scrollToEnd lands one frame after each measurement, so every streamed change stays behind for 2 frames (140 lag frames for 70 changes in each failing run). When a frame takes more than about half of the 110 ms between changes, the next change arrives before the reader has caught up. We reproduced it locally with the unmodified script on 2 cores: under 2x CPU throttling main failed 4 of 4 runs (4 to 10 frames behind), and with 2 CPU hogs it failed twice in 8 rounds.

The same follow also stops following in the script's fold-together step, where a row above folds as the last row grows. LegendList's own adjustment, and then a scrollToEnd aimed at an end it computed before the rows moved, net out as a move up with no shrink seen, and the chat reads that as the reader leaving. It happened in 3 of those 8 rounds on main and 2 of 6 on this commit. The glide it replaced passed that step in every run.

A follow that lands in the same frame, with your measured gain kept, is being worked on for the re-land, and it will reuse your check. Thanks again.

devswha added a commit that referenced this pull request Nov 3, 2026
…2647)

## Change

Reverts #2624 (9ef34f8). An improved version will land again on top of
this.

#2624 follows a growing chat row with LegendList's `scrollToEnd`, which
lands one frame after each measurement. Each streamed change is
therefore two frames behind: the frame it is appended in, and the next
one. On a fast machine that passes. On GitHub-hosted runners the frames
are slower, so the next change often lands before the reader has caught
up. `scripts/chat-virtual-browser-qa.ts:717` ("continuous content left
the reader behind for N consecutive animation frames") then failed on
every attempt of fork PRs: #2616 twice (4 frames), and #2636 (6 frames).
In each of those runs every change lagged exactly 2 frames (140 lag
frames for 70 changes).

The same follow also stops following in the script's fold-together step.
A row above the view folds as the last row grows. LegendList's own
adjustment, followed by a `scrollToEnd` aimed at an end it computed
before the rows moved, nets out as a move up with no shrink seen.
ChatView then reads that as the reader leaving. The glide it replaced
did not do this.

Reproduced locally with the unmodified script, alternating commits under
`dori heavy`:

| Profile | 05c9133 (before #2624) | 9ef34f8 (#2624) | main |
|---|---|---|---|
| 2 cores, 2 CPU hogs, 8 rounds | 0/8 (p95 bound, the old glide's known
lag) | 3/6 valid (fold-together 2, line 717 once) | 3/8 (fold-together
3, line 717 twice) |
| 2 cores, 2x CPU throttle (CDP) | not run | not run | 0/4 (line 717,
4-10 frames) |

The 05c9133 column shows why #2624 was written: the old glide fails the
p95 bound that #2624 added. That bound goes away with this revert,
together with the rest of #2624's script changes.

## Validation

- `scripts/chat-virtual-browser-qa.ts` (as reverted): 1 consecutive
passes so far (the run continues to 22; I'll update this line) on 2
cores with 2 CPU hogs and 2x CDP CPU throttle, `UI_EVIDENCE_DIR` set as
CI does, under `dori heavy`. The fold steps passed in every run.
- `bun run check fast`: success on a5c4e5b8 (same tree as this head;
only the commit message changed).
- omo code reviewer: APPROVE. The revert is exact: `git diff` against
9ef34f8~1 is empty for the reverted files. Nothing merged after #2624
depends on it, and the fragment was never released. Its LOW is applied:
the commit message no longer claims grow-above.
- Skipped: full check, Windows VM.

No changelog fragment: #2624's own fragment was not released yet, and
this revert removes it.
devswha added a commit to cheol-95/ovis that referenced this pull request Nov 3, 2026
devswha added a commit to duytbp/ovis that referenced this pull request Nov 3, 2026
devswha added a commit that referenced this pull request Nov 3, 2026
…and)

#2624 followed a growing chat row with LegendList's scrollToEnd, which lands a
frame or more after the measurement: each streamed change stayed two frames
behind, and on a slow runner the next change came first (#2647 reverted it).

The follow now runs in a microtask queued after the list's own, once per batch
of measurements, and puts the view at the list's measured end (its content
length, not the scroller's height, which the list pads for a frame while it
holds rows in place). A scroll the chat has not heard yet is read first, so a
reader who left the end is not pulled back. The glide, its scrollend settle and
followGlides go: nothing glides any more. While the list pads its content for a
frame (its ScrollAdjust room), neither this follow nor ChatView's short-landing
fix aims at the scroller's end, which lies past the list's there.

#2624's continuous-content check is back, and the QA script takes
CPU_THROTTLE (Chrome CPU throttling) to run it as a slow runner does. It
now also bounds frames painted behind, follows a stream React renders, and
opens a long chat while the agent works, with and without reduced motion.

Co-authored-by: Oded Falik <33235305+odfalik@users.noreply.github.com>
devswha added a commit that referenced this pull request Nov 3, 2026
…and)

#2624 followed a growing chat row with LegendList's scrollToEnd, which lands a
frame or more after the measurement: each streamed change stayed two frames
behind, and on a slow runner the next change came first (#2647 reverted it).

The follow now runs in a microtask queued after the list's own, once per batch
of measurements, and puts the view at the list's measured end (its content
length, not the scroller's height, which the list pads for a frame while it
holds rows in place). A scroll the chat has not heard yet is read first, so a
reader who left the end is not pulled back. The glide, its scrollend settle and
followGlides go: nothing glides any more. While the list pads its content for a
frame (its ScrollAdjust room), neither this follow nor ChatView's short-landing
fix aims at the scroller's end, which lies past the list's there.

#2624's continuous-content check is back, and the QA script takes
CPU_THROTTLE (Chrome CPU throttling) to run it as a slow runner does. It
now also bounds frames painted behind, follows a stream React renders, and
opens a long chat while the agent works, with and without reduced motion.

Co-authored-by: Oded Falik <33235305+odfalik@users.noreply.github.com>
devswha added a commit that referenced this pull request Nov 3, 2026
…and) (#2655)

## Change

Re-lands @odfalik's #2624 (reverted in #2647) in a form that holds on
slow machines.

#2624 replaced the chat's smooth follow glide with LegendList's
`scrollToEnd` whenever a row's measured size changed. That call lands a
frame or more after the measurement, so each streamed change stayed two
frames behind. On GitHub-hosted runners the next change often came
first, and `scripts/chat-virtual-browser-qa.ts` failed there (#2616,
#2636). Its fold-together case also stopped following.

This PR follows a measured row before the frame paints:

- **Same-frame follow.** `ChatTimeline` queues its follow in a microtask
after LegendList's, once per batch of measurements. LegendList sets its
total before it calls `onItemSizeChanged`, and React draws that total in
the microtask it queued then. The follow then puts the view at the end.
- **Where "the end" is.** That is the scroller's end, except while
LegendList pads the content for a frame to hold rows in place (its
`ScrollAdjust` sets an inline `padding-bottom`). During that frame the
scroller's end lies past the list's, and following it is what broke the
earlier same-frame attempt and fold-together. `listEndPadded` detects
it. Outside `anchoredEndSpace`, which the chat doesn't use, that inline
padding is the only one LegendList sets. ChatView's short-landing fix
(`stoppedShort`) skips that frame too.
- **A reader who left is not pulled back.** Before moving the view, the
follow reads any scroll the chat hasn't heard yet, exactly as its scroll
event would, and re-checks that the reader is still following. "Heard"
starts at the position the list attached with. Before that change, the
first follow in a short chat called the chat's scroll handler with no
scroll at all, and at the top that handler loads the earlier page. CI
caught it in `scripts/chat-history-browser-qa.ts`: the Earlier messages
button had already been used.
- **No glide.** The glide, its `scrollend` settle and `followGlides` are
removed, since nothing glides any more. The changelog says so.
- **Kept from the merge pass on #2624.** Closing a disclosure while
following drops a scroll the list already queued.

Like the Codex and Claude apps, the chat holds the end of a streaming
answer without animating to it. I'm describing those apps from use; I
didn't re-measure them for this PR.

### QA script

`scripts/chat-virtual-browser-qa.ts`:

- **`CPU_THROTTLE`.** Takes Chrome CPU throttling (`CPU_THROTTLE=2`) to
run as a slow runner does.
- **Continuous-content check.** #2624's check is back, with two changes:
- **What the reader sees is measured, not the moment after an append.**
#2624 sampled the gap right after each append, before any layout. Every
frame that carries a change counted as behind, whatever was painted. On
a runner slow enough that several frames in a row take a change's 110 ms
each, that run grows with the machine, not the follow. In one 2x run
here, four such frames in a row gave a pre-layout run of 4 while 0
frames were painted behind. That pre-layout run is still logged. Two
checks now look at what is painted:
- **Painted behind:** at most 10% of changes. A ResizeObserver on the
streamed content, created after the list's own, runs in each frame the
row grew: after layout, after the list's measurement and the chat's
follow, before that frame paints. Callbacks run in creation order with a
microtask checkpoint between them
([ResizeObserver](https://drafts.csswg.org/resize-observer/#resize-observer-interface),
[HTML](https://html.spec.whatwg.org/multipage/webappapis.html#clean-up-after-running-script)).
- **Consecutive frames behind:** `<= 2`, unchanged, read as each next
frame starts.

The lane lead approved this change on the condition that it still fails
on main and on #2624's code at 2x. The controls below show it does.
- **A held-up frame lands every change that came due meanwhile at
once**, as a stream backlog does. Before, the fixed 110 ms grid appended
one overdue change per frame after a stall.
- **Rendered stream.** A new case grows the last answer through the
chat's own conversation reads. React renders each paragraph, and the gap
is read in the frame the row grew, after the list measured it. A
ResizeObserver made after the list's own runs at that point.
- **First open.** The long-chat first-open case now also runs with the
agent working, with and without reduced motion. Main misses the end by
1,117 px in one frame at 1440 while working.
- **Follow cases no longer require a glide in progress.** The old
`during > 1` check is replaced by #2624's "the grown last row was not
followed".
- **Wheel-up cleanup.** Taking the test's blocks out can shrink the
transcript under a reader who went away onto its end. That counts as the
end reached, so the cleanup clicks New messages only while it is still
shown. The case's assertions run before this step and are unchanged.

## Evidence

All numbers are from desktop Chromium at phone and desktop widths, not
iOS Safari.

| ChatView QA fixture | main p95 gap | #2624 p95 gap | this PR p95 gap |
|---|---:|---:|---:|
| 1440×900 | 119 px | 41 px | 22 px |
| 390×844 | 120 px | 96 px | 96 px |

- **Frames painted behind, the same script at 2 cores + 2 CPU hogs +
`CPU_THROTTLE=2`:**

| Code | 1440 painted behind | 390 painted behind | p95 1440/390 |
Result |
  |---|---:|---:|---:|---|
  | this PR | 0 of 70 | 0 of 70 | 22 / 96 px | pass |
| #2624 (9ef34f8) | 69 of 70 | not reached | 41 px / not reached |
fails painted behind, then fold-together at 390 before its stream |
  | main | 70 of 70 | 70 of 70 | 100 / 194 px | fails all three bounds |
| this PR with its follow moved to `setTimeout(0)`, after paint | 70 of
70 | 70 of 70 | 22 / 96 px | fails painted behind (read as the next
frame starts, it shows 0) |
- **Rendered stream.** 0 of 38 changes behind at both widths.
- **Failing-first.** Main with this script fails both unthrottled and on
2 cores at 2x CPU throttle: continuous content, the rendered stream, and
first open while working.
- **Reader escape.** An upward wheel and a touch drag both stop
following while rows stream, at 1440 and 390, every run (probe). On
main, one 390 touch drag didn't escape.
- **#2650, drag selection while streaming.** The selection drifts the
same on main and on this PR (about 550–790 px moved, probe). This PR
doesn't make it worse; that issue stays open.

## Validation

- `bun scripts/chat-virtual-browser-qa.ts`, on 2 cores with 2 CPU hogs
and `CPU_THROTTLE=2` under `dori heavy`, with `UI_EVIDENCE_DIR` set as
CI does: **20 of 20 consecutive passes on this head** (61e231b). Every
run painted 0 of 70 changes behind, at both widths. Before the last fix
(a short chat loading its earlier page by itself), the previous head had
18 of 18.
- `bun scripts/chat-virtual-browser-qa.ts` unthrottled: pass.
- **The other 18 chat browser scripts** (every
`scripts/chat-*-browser-qa.ts` and `chat-*-regression.ts`), on 2 cores
at 2x: 17 pass. `chat-browser-qa.ts` fails the same way on main, with
and without throttling (#2654). CI doesn't run that script.
- `bun run check fast`: pass (10,792 unit tests, typecheck, build).
- Reviews: omo and Codex Terra (gpt-6.1-terra, xhigh) reviewed the
change and each of three rounds of fixes; both approve the final head.
Fixed from review: painted lag unasserted, no React-rendered stream, no
backstop for a long chat opened while working (also under reduced
motion), an unused `during` value, a flaky wheel-up cleanup, and painted
sampling that a follow landing between frames could pass. Left as notes:
- `listEndPadded` reads a LegendList internal (inline padding from
`ScrollAdjust`; `anchoredEndSpace` is unused here).
  - Chromium is the only engine checked.
- As on main, an automatic move that reaches the scroll handler near the
top can still ask for the earlier page. This PR no longer makes one
without a move.
- CI on GitHub-hosted runners, dispatched with `workflow_dispatch`
because a same-repo PR runs on self-hosted ones: [run
41627972491](https://github.com/devswha/ovis/actions/runs/41627972491)
on this head passed all 10 jobs (ubuntu-24.04, windows-latest,
macos-15). That includes `chat-virtual-browser-qa.ts` (129.5 s) and
`chat-history-browser-qa.ts` (13.5 s).
- Not run here: the full integration suite, native Windows, and a real
iOS device. No HTTP/WS contract, dependency or stored-format change.

Co-authored with @odfalik: his #2624 regression check and disclosure fix
are reused.

Co-authored-by: Oded Falik <33235305+odfalik@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.

2 participants