Skip to content

fix(virtualizer): hold the reader across a mid-list row splice - #7198

Merged
iamwhatever merged 1 commit into
mainfrom
fix/streaming-ghost-row-jitter-6076
Sep 2, 2026
Merged

fix(virtualizer): hold the reader across a mid-list row splice#7198
iamwhatever merged 1 commit into
mainfrom
fix/streaming-ghost-row-jitter-6076

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Why no screenshot: scroll-position fix with no static visual delta — the change is where the viewport sits across a commit, which a before/after still cannot show. Reproduced numerically instead (100px / 400px / 200px of drift in the harness).

Problem / Motivation

While a turn streams, transient "thinking" rows mount and unmount above or between output that is already rendered. Each whole-row add/remove jogs the viewport under a reader who has scrolled up: the transcript is pushed down when the row appears and pulled up when it goes. Token growth inside an existing line is already smooth — it is the row-level insert/remove that jitters.

Not the two anchor fixes that landed this week: #6645 collapsed two captures onto one render-phase slot, and #6949 added TRIGGER 3 for a tail append. Not #4399's pinned-jump glide either, which is untouched.

Why it matters

Anyone reading back through a transcript while an agent is still working — the normal way a long turn is followed. The row they are reading walks off under them, repeatedly, for the duration of the turn. On engines with no native scroll anchoring (iOS Safari) nothing absorbs it.

What changed (motivation → approach → change)

Everything below extends the one existing render-phase capture and its one consumer. No parallel anchor path — a second path fights this one for scrollTop.

1. A mid-list INSERT was swallowed by TRIGGER 3's predicate. tailAppended only tested "the count grew while index 0 kept its key", which a mid-list splice satisfies too — so it took the append path. That path resolves a mounted node's previous-commit index through the new items, which is only correct when no index moved; after a splice it names the wrong row, so the anchor was mis-keyed and the correction measured a row nobody was reading.

Split on the one O(1) fact that separates them: a tail append leaves the last pre-existing index answering to its own key, while a splice anywhere above it moves that row along. The splice case (TRIGGER 4) gets TRIGGER 1's mapping — previous items at the node's previous index, filtered to keys that survive the commit, so a retired key falls forward to the next survivor.

2. A mid-list REMOVE had no trigger at all. An unmounting "thinking" row is exactly that. TRIGGER 5 covers it through the same capture and the same consumer: the height above the reader changing is one event, and the correction does not care which direction it moved.

3. The estimate-vs-measured reprice, at its source. getHeight prices every unmeasured row from the running MEAN of the measured ones, so a measurement is never local to its own row. A transient row is measured while mounted and then leaves — and its height goes on pricing the transcript after the row is gone, so everything above the reader stays under-priced until the entry is evicted, and past a reload once the blob is persisted. Compensating one commit cannot reach that: the reprice recurs on every later sync.

New HeightIndex.retire / HeightCache.retire drop the height from the mean during the render that dropped the row, so the reprice lands in the commit the splice anchor already compensates. Two properties earned during review:

  • Keyed on key departure, not on the net count falling. A commit that drops the ghost while adding output nets to growth or to zero with its height still pricing the transcript. The surviving-key set the anchor already builds is the general detector, so this costs one pass over the previous items and no extra allocation. That includes TRIGGER 6, the equal-count swap (the placeholder leaving and its replacement arriving in one React-batched commit).
  • Retiring KEEPS the measurement and removes it only from the mean. Deleting it would corrupt a rollback: handleRegenerate and handleEditResend both snapshot the transcript, optimistically truncate it, and dispatch the snapshot back when the server refuses the press — so rows leave the list and come back, and rows restored without their measurements would be re-priced from the mean (wrong spacer, viewport jump, and it persists for every row still off-window). A later set() revives a retired key, retired entries keep their LRU position so they evict before live ones, and they are left out of the persisted blob so a reload does not re-admit them to the mean.

Retirement is reversible, because removal is not always permanent. A retired key is un-retired by HeightCache.reviveIfRetired, called from the resolved-height read in HeightIndex.heightAt. That read is the right site rather than a removal site: a restore has no commit shape of its own to hook (an optimistic tail truncation comes back as a plain append, which is not a splice at all), while a key resolving from a live row index is itself proof the retirement's premise no longer holds. Without it a restored row kept its own exact height but stayed out of the mean, so it never priced the unmeasured rows again — and an off-window row, which never re-measures, stayed out indefinitely.

What TRIGGER 6 deliberately does NOT do: capture an anchor. The single consumer is invalidated by windowRange and itemCount, and an equal-count commit moves neither, so an anchor taken there would sit in the slot and be spent on an unrelated later commit — the stranded-anchor hazard the render-phase capture exists to remove. Compensating a swap needs a new invalidation key in that shared consumer, which changes a contract every trigger depends on; filed as #7234 with the measured 200px reproduction.

Every trigger stays behind the stickRef guard: a reader pinned to the bottom keeps following the output down.

Diff is confined to website/src/hooks/virtualizer/ and its tests. No reformatting.

Tests

Each case red-before proven by reverting only its own production change and keeping the tests:

New case Reverted change On the revert On this branch
prependAnchor: row spliced in above the reader TRIGGER 4 drifted 100px holds (≤1px)
prependAnchor: row removed above the reader TRIGGER 5 drifted 400px holds (≤1px)
heightSyncAnchor: ghost row leaves the list retirement totalHeight stuck at 6386.67 returns to 9000
heightSyncAnchor: rolled-back row restored retire → delete came back at the flat estimate 80 its own 900
heightSyncAnchor: swapped-out row at equal count TRIGGER 6 totalHeight 7110 9000
virtualizerHeightOwner: restored row back in the mean reviveIfRetired priced at 300 (mean without it) 500
× holds the reading position when a row is SPLICED IN above the reader
  AssertionError: expected 100 to be less than or equal to 1
× holds the reading position when a row is REMOVED above the reader
  AssertionError: expected 400 to be less than or equal to 1
× stops pricing unmeasured rows from a transient row once it leaves the list (pinned reader)
  AssertionError: expected 6386.666666666669 to be close to 9000
× restores a rolled-back row at its own measured height, not the mean
  AssertionError: expected 80 to be 900
× retires a swapped-out row even though the count never moved
  AssertionError: expected 7109.999999999995 to be close to 9000

Plus seven HeightCache cases pinning the retire/revive contract (out of the mean, still readable, revived on re-measure, revived when its row is live again, idempotent revive, estimate fallback when every sample is retired, not persisted while retired and re-persisted once revived), and two pinned-to-bottom counterparts — still follows to the bottom when a row is SPLICED IN while PINNED and keeps following after a row is REMOVED while PINNED — which pass on main and stay green, so a fix that held position for a pinned reader would fail them.

Non-vacuity is asserted in each case: the ghost really mounts, the removed row really is gone.

No existing assertion weakened. Virtualizer family 198/198 green (anchorRestore, heightSyncAnchor, integration, layoutShrink, observerBackfill, postStreamLurch, prependAnchor, railCollapse, spacerLurch, viewportResize, UseVirtualChatCoverage, HeightCache, HeightCache.multiInstance, virtualizerHeightOwner, ScrollAnchorCache). Full website suite green; tsc -b and eslint clean on the changed files.

Manual verification

N/A — the jitter is a scroll-position delta, and the harness reproduces it numerically: the deterministic layout engine in prependAnchor.test.tsx makes the rows genuinely move, so every number in the table above is measured, not asserted against source text.

Pattern harvest

Rule candidate: review checklist (not mechanically greppable)

Pattern: a cache retraction keyed on "the row left the list" must not treat removal as permanent when the app has optimistic-then-rollback flows. The first version of the retirement deleted the measurement, which is correct only if a departure is final. It is not: handleRegenerate and handleEditResend both snapshot, truncate, and restore the snapshot when the server refuses the press. The generalizable shape is that a derived-data store gets two independent questions — "should this still influence other entries?" (here: the mean) and "should this still be retrievable?" (here: the entry) — and collapsing them into one delete is what turns a correct invalidation into data loss. The same split appears in this file's own eviction, which is a capacity decision about rows that still exist and therefore is not a retraction at all. Two of the three review findings on this PR were instances of getting that split wrong in opposite directions (delete loses the restore; retire-without-revive loses the sample), which is why it is worth writing down rather than treating as a one-off.

Not mechanically enforceable: the trigger is "this key's row may come back", which no pattern can see from the call site. The checkable proxy is narrower and worth asking on any PR that removes an entry from a persisted cache — what restores this key, and does that path re-establish everything the removal took away?

Related Issues

Fixes #6076

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title

@iamwhatever
iamwhatever requested a review from a team August 31, 2026 03:37
@iamwhatever
iamwhatever requested a review from a team as a code owner August 31, 2026 03:37
@iamwhatever
iamwhatever requested a review from bolichen97 August 31, 2026 03:37
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of ce60c8a2f852fcdeb090c335f59e293d05db0acd — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Pure virtualizer-internals fix — no new strings, surfaces, or screenshots; the only user-visible effect is fewer viewport jumps during transient-row splices.

[UX-REVIEWED] ce60c8a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of ce60c8a2f852fcdeb090c335f59e293d05db0acd and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] ce60c8a

False positive or not applicable? A repository writer can comment:
/ai-review override gpt ce60c8a2f852fcdeb090c335f59e293d05db0acd: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of ce60c8a2f852fcdeb090c335f59e293d05db0acd — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Root-cause fix at the right layer: retirement splits "influences the mean" from "retrievable," and every trigger reuses the existing single capture/consumer path.

Watch

  • Retirement semantics hinge on onTopReached presence as a proxy for "head departures return." A paging consumer that genuinely deletes a contiguous head prefix keeps the dead rows' heights in the mean — and in the persisted blob — until cap eviction. Bounded and documented in-code, but it is a semantic coupling to an unrelated callback that a future consumer can trip silently; if a real case surfaces, promote it to an explicit prop rather than widening the shape heuristics.
  • The equal-count swap (per the description, the ordinary streaming exit shape) still shifts the viewport uncompensated — deferred to fix(virtualizer): the anchor consumer has no invalidation key for an equal-count row swap #7234 with sound stranded-anchor rationale, but it means Streaming text jitter: "thinking" ghost lines push already-rendered content up and down #6076's headline jitter is reduced, not eliminated; confirm that issue stays open or is re-scoped.

[DESIGN-REVIEWED] ce60c8a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of ce60c8a2f852fcdeb090c335f59e293d05db0acd — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims verified: the rollback mechanism the retire-vs-delete decision rests on is real (ChatPage.tsx:5652, :5770 — both snapshot, truncate, and dispatch(replaceMessages(snapshot)) on refusal), ChatPage wires onTopReached while the gallery does not, the keyAt seam the revive pass uses pre-exists, and the anchor capture reuses the one existing render-phase slot and consumer rather than adding a parallel path. Every production change has a red-before test with a measured drift number, and the one deliberately unfixed sibling (equal-count swap anchor) is declared and filed as #7234.

First-Principles-Verdict: PASS

Every item traces to the one reported defect (#6076), lands at the cause, and reuses the existing capture slot instead of growing a parallel anchor path.

What this change ships

Intent: stop the transcript jogging under a reader while transient rows mount/unmount mid-list — a FIX.

  1. Row spliced in above the reader no longer shifts the viewport — justified (100px red-before)
  2. Row removed above the reader no longer pulls the transcript up — justified (the Streaming text jitter: "thinking" ghost lines push already-rendered content up and down #6076 defect, 400px red-before)
  3. A departed row's height stops pricing the remaining rows — justified, cause-level
  4. Rolled-back rows return at their exact heights, not the mean — justified (mechanism verified at ChatPage.tsx:5652, :5770)
  5. Retired heights left out of the persisted blob — justified (a reload has no rollback)
  6. Retired entries evict before live measurements — justified (transient rows sit at the MRU end)
  7. Clearing or filtering the list drops the departed rows' pricing influence — justified (departure gate, tested)
  8. Head paging keeps its measurements, gated on the consumer wiring onTopReached — justified (rows return on scroll-up)
  9. Equal-count swap anchor deferred to fix(virtualizer): the anchor consumer has no invalidation key for an equal-count row swap #7234 — accepted-and-deferred, declared with a measured repro
  10. Exact moved-index scan replaces the last-index proxy the description sketches — justified (its misses are tested)

Watch

  • The description's "Split on the one O(1) fact… the last pre-existing index" is not what ships: the diff's own comment calls that form a proxy that misses interior replacements ("Exact, not sampled. The cheap proxy this replaces read only the LAST pre-existing index") and ships an O(N) scan. Same job, superseded mechanism — worth knowing the description trails the diff.

[FIRST-PRINCIPLES-REVIEWED] ce60c8a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed ce60c8a2f852fcdeb090c335f59e293d05db0acd — this comment is updated in place on each push.

Review details

I've examined the single candidate against the code in the diff.

Candidate 1 analysis — the claim is that reviveLiveRows performs an O(N) scan on every sync/syncAndAnnounce for the rest of a session once any transient row is retired (since a swapped-out "thinking" key is never re-measured, hasRetired() stays true until eviction at the 2000 cap).

Falsifying the three requirements:

  • (a) input: a session with a retired transient row — plausible, occurs in practice.
  • (b) call path: reviveLiveRowshasRetired() true → for i in [0,itemCount) keyAt(i) — verified in the diff.
  • (c) observable wrong outcome: this is where it fails. tree.sync already walks all itemCount rows via getHeight, so the added pass is a constant-factor (~2×) regression on an already-O(N) operation, not a complexity change and not a correctness defect — the geometry produced is correct. Whether an ~8×/sec extra O(N) key scan over a transcript is perceptible is unestablished; the candidate's own confidence is "low" and explicitly concedes it could not measure a real cost.

The only concrete artifact is that the comment's "overwhelmingly common case" premise is slightly optimistic for a swap-heavy streaming session — but that is a doc/comment quibble, a category this pipeline owns deterministically, not a reportable finding. The correctness of the retire/revive/evict/persist logic itself checks out (measuredSum stays in lockstep; revive is idempotent and only fires for keys resolving from live indices; retired keys are excluded from the mean and the persisted blob and evicted first).

No survivor clears the 80 bar. No additional grounded defect surfaced under review.

No findings.

[OPUS-REVIEWED] ce60c8a

Verdict parsed from the review's SHA-scoped output markers for commit ce60c8a2f852fcdeb090c335f59e293d05db0acd.

False positive or not applicable? A repository writer can comment:
/ai-review override fable ce60c8a2f852fcdeb090c335f59e293d05db0acd: <one-sentence reason>

@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from 81dd5f5 to 9cf2342 Compare August 31, 2026 04:33
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 31, 2026
@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from 9cf2342 to fe93043 Compare August 31, 2026 08:12
@iamwhatever

iamwhatever commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=0835bd537213 — Rollback corrupts restored row geometry — fixed

Legitimate, and reachable on two paths I confirmed on main rather than in the abstract:

Rejected edit/resend -> optimistic truncation forgets surviving history heights -> rollback restores rows without measurements while retaining the temporary row's measurement, producing incorrect spacers and viewport jumps.

handleRegenerate snapshots messages, dispatches truncateAfterIndex(uIdx + 1), and dispatches replaceMessages(snapshot) from the api.regenerateSlot catch. handleEditResend does the same through rewindWithRollback's refusal callback. So a row leaving the list is not always permanent, and the retraction assumed it was.

Fixed, but not by the suggested revert — Fix: Revert the departed-key measurement retraction. would drop the third defect this PR exists to close (a transient row's height pricing the transcript after the row is gone). The retraction has two halves with opposite failure modes, so they are now separated: keeping the height in the mean is the bug being fixed, because it prices every unmeasured row; deleting it is the bug you found, because a restored row is then re-priced from that mean.

HeightCache.retire therefore keeps the entry and removes it only from the mean. A restored row resolves its own exact measurement (peek/get still answer), a later set() revives the key into the mean, retired entries keep their LRU position so they evict before live ones, and they are left out of the persisted blob so a reload cannot re-admit them.

Red-before proven by reverting retire() to the deleting form and keeping the tests: the rolled-back row came back at the flat estimate 80 instead of its 900px measurement.

× restores a rolled-back row at its own measured height, not the mean
  AssertionError: expected 80 to be 900

New coverage: restores a rolled-back row at its own measured height, not the mean (the truncate/rollback round trip, plus the mean check while the row is gone) and five HeightCache cases pinning the retire contract — out of the mean, still readable, revived on re-measure, estimate fallback when every sample is retired, and not persisted.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions — Design Review 🟡 CONCERNS and First Principles ✅ PASS (Watch)

Both lanes named the same gap from opposite directions: the detectors key on a count delta while the harm keys on key departure, so the batched "thinking leaves + output arrives" commit falls through. Splitting it into the two halves it actually has:

1. The retirement half — fixed

survivingKeys is already built for both splice branches; run the departed-keys diff whenever that branch runs (any key in prependPrev.items absent from survivingKeys, minus the head-trim case), decoupling retraction from net-shrink at ~zero extra cost.

Adopted as written. Retirement no longer sits under rowsRemoved: it runs for the whole splice family off the survivingKeys set the anchor already builds, so it fires on an equal-count swap and on a commit that nets to growth while a key departs. Head paging stays excluded by the index-0 guard, which is what keeps a paged-out row's measurement in the mean that prices the rows the reader is about to page back into.

Red-before proven by reverting only the swap's admission to that branch: retires a swapped-out row even though the count never moved failed at 7110 instead of 9000 (the ghost's 20px still in the mean).

2. The anchor half — accepted-and-deferred, tracked in #7234

A single commit that removes the thinking row and appends/inserts output — one render under React batching, a realistic streaming shape — nets to equal count (neither trigger fires: the jitter recurs)

Correct, and I tried to close it here — the attempt is what showed why it does not belong in this PR. I added the swap as a capturing trigger, and the new case still failed with 200px of drift, because the single consumer is invalidated by windowRange and itemCount and an equal-count commit moves neither. So the capture was never consumed: the anchor sat in the slot for the next commit that did move one of those keys to spend on unrelated geometry — the stranded-anchor hazard the render-phase capture was introduced to remove. Shipping it would have been a regression wearing a passing test's clothes (the first version of that test was vacuous: with every harness row a uniform height, a 1:1 swap moves nothing).

Compensating a swap needs a new invalidation key in the consumer every trigger shares (a render-phase splice counter in part 2's deps and the offset memo's). That changes a contract anchorRestore, heightSyncAnchor, spacerLurch, postStreamLurch and layoutShrink all encode, so it wants its own regression pass rather than riding along. #7234 carries the mechanism, the measured 200px reproduction, and the harness change it needs. TRIGGER 6 in the code names the constraint and points there, so the next reader does not re-derive it.

The same note answers First Principles' Watch, which raised this as depth on a PASS.

@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from fe93043 to abdc284 Compare August 31, 2026 08:36
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 31, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Disposition — First Principles Review 🔴 BLOCK on fe93043581c9

Dead rider: HeightCache.forget(). Grepped \.forget\( across website/ — 0 matches: no production caller, no test, and the PR description never names it.

fixed — accepted without argument, and the diagnosis is exactly right. forget() was the round-1 mechanism; round 2 replaced it with retire() and added the new method beside it instead of in place of it, so a dead method shipped whose own sibling docstring argues its behaviour is wrong. Deleted; retire() is the shipped mechanism, and grep -rn '\.forget(' website/src is now empty.

Nothing else was riding along: the diff is retire / reviveIfRetired on the cache, retire on the owner, TRIGGERS 4/5/6 in the hook, and their tests.

Also worth recording against item 5 of your inventory, since the swap's anchor half is the one declared-deferred piece: the deferral is not scope-trimming for convenience. I implemented it, and the new case still failed with 200px of drift, because the single anchor consumer is invalidated by windowRange and itemCount and an equal-count commit moves neither — so the capture was never consumed and the anchor sat in the slot for an unrelated later commit to spend. That is the stranded-anchor hazard the render-phase capture was introduced to remove, so shipping it would have been a regression with a green test. #7234 carries the mechanism and the reproduction; TRIGGER 6's comment names the constraint in the code.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from abdc284 to 831f64e Compare August 31, 2026 16:34
@iamwhatever

iamwhatever commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author
  • span=0835bd537213 — Interior replacements bypass height retirement — fixed

Legitimate. rowsSwapped read only the last pre-existing index, so a replacement anywhere above it satisfied the boundary check while the replaced row's measurement stayed in the mean that prices every unmeasured row.

Fixed as suggested — any departed position, not a changed last index — and the same proxy turned out to gate the GROW path too: an interior replacement alongside a tail append also kept the last index's key, so tailAppended swallowed it. Both now key on one exact positional scan, and oldIndicesHeld is gone rather than duplicated.

The scan is affordable because it is not a re-keying one. The overwhelmingly common commit is a token append, which rebuilds the array while reusing every element object except the streaming row's, so reference equality settles those positions with no getKey call and no allocation; a key is computed only where an object actually changed, which is the only place a departure can hide.

Red-before proven by restoring the boundary proxy and keeping the tests:

× retires an INTERIOR row replaced at equal count
  AssertionError: expected 13050 to be close to 9000
× retires an INTERIOR row replaced while another is appended
  AssertionError: expected 15100 to be close to 9300

Three new cases: the two above, plus retires nothing when a commit only rewrites one row in place — the token-append shape, which must retire nothing and does not, so the widened detector cannot start eating live measurements.

@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 31, 2026
@github-actions github-actions Bot added the readiness: action required A blocking check or review needs attention label Sep 1, 2026
@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from 74f85f7 to e373e25 Compare September 1, 2026 16:02
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed in e373e2576 -- span=0835bd537213, website/src/hooks/virtualizer/useVirtualChat.ts, "Prepend regroup is mistaken for head paging".

Older-history prepend -> top row regroups under a new lead key -> stale height remains in the mean and persisted cache, corrupting spacer offsets.

The finding holds. A prepend that regroups the top turn departs index 0's row while the count GROWS, and that departure satisfies both properties the previous round used to recognise head paging: the departures are a contiguous prefix, and survivors remain. So it was classified as a page-out and the regrouped row's measurement stayed in the mean.

This is the fifth blocking finding on this span, and the count-fell requirement the finding names is adopted -- not as a special case for prepends, but because it completes a definition the previous round left underspecified. Head paging is the one departure that must not retire, and it is now identified by all THREE of its properties, since any two of them are also true of a departure that must retire:

Property The shape that has the other two and must still retire
the count FELL a prepend regroup: prefix departure, survivors remain, count grows
the departures are a contiguous PREFIX a tail truncation or interior removal: count falls, survivors remain, but a survivor sits ABOVE a departure
a survivor REMAINS a full clear: count falls, departs a prefix, and is not coming back

Every other departure shape retires, and each is covered by one of the three: interior removal by the prefix test, equal-count swap and interior-replacement-plus-append by the count test, tail truncation by the prefix test, clear by the survivor test. A single row leaving the very head satisfies all three and is skipped, which is the behaviour that predates this branch.

Red-before proven by reverting only the new conjunct: a prepend that regroups the top row away kept its 900px in the mean and priced the transcript at 19600 instead of 12000. Virtualizer family 210/210 green across 17 files, rebased onto c18141d2e, no existing assertion weakened.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 1, 2026
@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from e373e25 to d1eb59b Compare September 1, 2026 21:00
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • fixed in d1eb59bc3 -- span=0835bd537213, website/src/hooks/virtualizer/useVirtualChat.ts, "Prefix-only filters are mistaken for history paging".

Artifact filter leaving a trailing suffix -> paging exemption -> removed card heights corrupt the mean and spacer offsets.

Confirmed against the real caller rather than treated as hypothetical. LibraryList in ArtifactsPage.tsx calls the virtualizer with no onTopReached, so that gallery cannot page, and its items come from visible/scopedVisible -- the search-box and pinned-only filter results. Narrowing the box drops a leading run of cards while later ones survive, which satisfies every shape property the exemption tested, so the filtered-out cards' heights kept pricing the survivors. Its sessionId is a shared constant, so those heights would price every later gallery render too.

The suggested basis is adopted, and it is the one that ends this finding class rather than adding a fourth shape test: paging is a CAPABILITY THE CALLER DECLARES, not something inferable from the diff between two item lists. Nothing pages unless the consumer asked to be told when the reader reaches the top, so a consumer without onTopReached has no page-out to exempt and every departure it makes is final. The prop is read directly rather than through its ref, which an effect refreshes a render late.

Within a paging consumer all three shape properties still apply, because any two of them also hold for a departure that must retire:

Property The shape that has the other two and must still retire
the consumer CAN page the artifacts gallery: prefix departure, survivors remain, count falls -- and no paging
the count FELL a prepend regroup: prefix departure, survivors remain, count grows
the departures are a contiguous PREFIX a tail truncation or interior removal: a survivor sits ABOVE a departure
a survivor REMAINS a full clear: departs a prefix, and is not coming back

Red-before proven by reverting only the new conjunct: the filtered-out prefix kept pricing the survivors at 10000 instead of falling back to the flat estimate at 1600. The existing paging-exemption test now wires onTopReached, which is what makes it a paging consumer -- the exemption it asserts is only reachable there. Virtualizer family 211/211 green across 17 files, the 14 gallery suites 283/283 green (the caller whose behaviour this changes), rebased onto ab3352e9c, no existing assertion weakened.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 1, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • rebutted -- span=0835bd537213, website/src/hooks/virtualizer/useVirtualChat.ts, "Retirement sync leaves the notification baseline stale". The mechanism is described correctly but the consequence does not hold, and the suggested fix would regress two designed properties.

Equal-height replacement -> stale baseline suppresses syncAndAnnounce -> spacer geometry remains corrupted.

The tree is mutated BEFORE the epsilon check, unconditionally:

this.reviveLiveRows(itemCount)
this.tree.sync(itemCount, this.getHeight)      // always runs
const total = this.tree.totalHeight()
if (Math.abs(total - this.lastAnnouncedTotal) <= ANNOUNCE_EPSILON_PX) return

The early return skips the baseline write, beforeNotify, the version bump and the subscriber notify. It does not skip tree.sync, so the spacer geometry is already correct at the point it returns. What the stale baseline can suppress is a RE-RENDER, and only on a commit whose total moved by <= ANNOUNCE_EPSILON_PX (1px) -- a delta the spacer cannot express, which is the stated reason the threshold lives in the owner rather than at the call site.

The proposed reset would regress two properties that are deliberate:

  1. syncAndAnnounce documents that it does NOT update the announced baseline when nothing is announced, "so a later sync still sees the full accumulated delta". Resetting the baseline in retire() discards that accumulation, so a sequence of sub-epsilon syncs that together cross the threshold would stop announcing at the point the baseline was reset.
  2. Resetting to the "nothing announced yet" sentinel forces an announce on the next sync even when the total genuinely has not moved, which is a spurious re-render on every retirement -- the render storm the caller's debounce and this threshold both exist to prevent.

The offsetting-rows reading is also unreachable here. Retirement drops one key from the mean, so every unmeasured row re-prices in the SAME direction and the deltas cannot cancel; measured rows are untouched by retirement. A same-total outcome therefore means the per-row prices did not materially move either, so there is no hidden offset corruption behind an unchanged total.

Span hit count: this is the 8th blocking finding on 0835bd537213. The previous seven were each accepted and fixed with a red-before proof. This one is declined on the evidence above rather than patched, and the concentration itself is being raised with the maintainer separately.

A transient "thinking" row that mounts and unmounts between rows that are
already on screen jogs the viewport, while token growth inside an existing
line is smooth. Whole-row add/remove is the difference, and the render-phase
anchor capture covered none of its shapes.

Everything below extends the ONE existing render-phase capture and its ONE
consumer -- no parallel anchor path, which would fight this one for scrollTop.

1. A mid-list INSERT was swallowed by TRIGGER 3's predicate. `tailAppended`
   only tested "count grew while index 0 kept its key", which a splice
   satisfies too -- so it took the append path, which resolves a mounted
   node's PREVIOUS-commit index through the NEW `items` and therefore anchored
   on a mis-keyed row. It now keys on whether any PRE-EXISTING position
   changed hands, and the splice gets TRIGGER 1's old-items,
   survivor-filtered mapping.

2. A mid-list REMOVE had no trigger at all. TRIGGER 5 covers it through the
   same capture and the same consumer.

3. The estimate-vs-measured reprice, at its source. `getHeight` prices every
   UNMEASURED row from the running MEAN of the measured ones, so a measurement
   is never local to its own row: a transient row's height goes on pricing the
   transcript after the row itself is gone.

   One invariant governs the fix, in three axes:

     A retirement must be COMPLETE, NON-DESTRUCTIVE, and EFFECTIVE in the
     commit that dropped the row.

   COMPLETE. Retirement keys on KEY DEPARTURE, not on the net count falling: a
   commit that drops the ghost while adding output nets to growth or to zero
   with its height still pricing the transcript. Departure is found by an
   EXACT positional scan, so an interior replacement counts -- a boundary probe
   reads an artifact card refreshed in place as a no-op. The scan is
   affordable because a token append rebuilds the array while REUSING every
   element object but the streaming row's, so reference equality settles those
   positions with no `getKey` call and no allocation.

   NON-DESTRUCTIVE. Removal is not always permanent: `handleRegenerate` and
   `handleEditResend` both snapshot the transcript, optimistically truncate
   it, and dispatch the snapshot back when the server refuses the press. So
   `retire` drops the height from the MEAN and keeps the entry, and
   `reviveIfRetired` puts it back when a live row index resolves that key
   again -- the only available signal, because an optimistic TAIL truncation
   comes back as a plain append and has no commit shape of its own. Two
   orderings follow, both of which had it backwards before: eviction drains
   the retired set FIRST (a transient row is measured just before it leaves,
   so LRU order would drop a live row instead), and revival runs as a pass
   BEFORE the tree walk (mid-walk it would leave every row priced earlier in
   that same walk holding the stale mean, with no later sync guaranteed).

   EFFECTIVE IN THAT COMMIT. The tree is re-synced at the retirement site, not
   left to the `offsetIndex` memo: that memo is keyed on `itemCount`, and an
   equal-count SWAP moves none of its dependencies, so its body would not run
   and the spacers would keep prices the retirement just invalidated.

   Retired entries are left out of the persisted blob while retired (a reload
   has no snapshot to roll back to) and persisted again once revived.

RETIREMENT IS GATED ON DEPARTURE, NOT ON A COUNT SHAPE. Three separate commits
reached the retirement site with a row's measurement still pricing the
transcript -- an equal-count swap, an interior replacement, and a full-session
`/clear` -- because retirement borrowed the ANCHOR's triggers, and those are
built from count arithmetic plus `frontKeyHeld`. The anchor needs both: a
renamed index 0 means the mounted nodes' indices no longer name their own rows,
and a count that does not move leaves part 2 (invalidated by `windowRange` and
`itemCount`) not running to spend what was captured. Retirement needs neither,
and the clear is what made the borrowing indefensible: emptying the list
renames index 0 exactly as paging out the head does, so the proxy read a wipe
as a page-out and carried the old conversation's heights into the next one.

The gate is now stated once, at the level the harm lives on -- A ROW LEFT THIS
SESSION -- which requires either a shrinking count or a shared index changing
hands, so the streaming commit (same rows, one more at the tail) still does no
work.

Head paging is the ONE departure that must not retire, because its rows come
back when the reader scrolls up. Recognising it starts from the CALLER, not from
the data: nothing pages unless the consumer asked to be told when the reader
reaches the top, so a consumer with no `onTopReached` has no page-out to exempt
and every departure it makes is final. That is what separates the transcript
(ChatPage, which wires it) from a filtered list -- the artifacts gallery does
not wire it and feeds the virtualizer a SEARCH RESULT, so narrowing the box
drops a leading run of cards and keeps later ones, which no shape test can tell
from a page-out, and those cards are not coming back. Its `sessionId` is a
shared constant, so the stale heights would price every later gallery render.

Within a paging consumer all three shape properties are still required, since
any two of them are also true of a departure that must retire: the count FELL (a
prepend regroup also drops a prefix row while survivors remain, and it grows the
count), the departures are a contiguous PREFIX (a tail truncation or an interior
removal leaves a survivor above a departure), and a survivor REMAINS (a clear
departs a prefix and nothing else, and is not coming back). Every shape is
covered: interior removal by the prefix test, equal-count swap and
interior-replacement-plus-append by the count test, tail truncation by the
prefix test, clear by the survivor test, and any departure at all in a
non-paging consumer by the capability test.

That subsumes the equal-count trigger, which existed only to reach retirement,
so it is gone rather than left as a trigger with no anchor and no consumer. The
stranded-anchor hazard it would have carried is unchanged and still needs a new
invalidation key in the shared consumer, which wants its own regression pass:
filed as #7234.

Every trigger stays behind the `stickRef` guard: a reader pinned to the bottom
keeps following the output down.

Tests -- each red-before proven by reverting just its own production change:
- prependAnchor: splice-in above the reader drifted 100px; remove above the
  reader drifted 400px. Two pinned-to-bottom counterparts pin the stick guard.
- heightSyncAnchor: after the ghost left, totalHeight stayed at 6386.67
  instead of returning to 9000; a rolled-back row came back at the flat
  estimate 80 instead of its own 900px measurement; an INTERIOR replacement
  kept pricing the transcript at equal count (13050 instead of 9000) and
  alongside an append (15100 instead of 9300); a swapped-out row left the tree
  at its pre-swap 6200 instead of 9000 when the sync was left to the memo. A
  commit that only rewrites one row in place -- the token-append shape -- must
  retire nothing, and does not.
- virtualizerHeightOwner: with revival inside the read, the rows the walk
  reaches first kept the stale mean (2100 instead of 2500).
- HeightCache: nine cases pin the retire/revive contract -- out of the mean,
  still readable, revived on re-measure, revived when its row is live again,
  idempotent revive, estimate fallback when every sample is retired, not
  persisted while retired but re-persisted once revived, retired evicted ahead
  of an older live row, and LRU order resumed once no retired entry is left.

Rebased onto #7207, which made a caller's `getKey` INDEX-ADDRESSED (ChatPage
resolves a per-render deduped key LIST by position). Every lookup this commit
adds that reads the PREVIOUS render's items -- the positional departure scan,
the splice anchor's key resolver, and the departed-key pass -- therefore prices
them with the `getKey` captured WITH them, the contract #7207 established for
the prepend capture; the current render's closure returns the NEW list's key at
an old index. Two tests pin the interaction, both red-before: an interior
removal under a positional getKey retired nothing and kept pricing the
transcript at 14100 instead of 8700, and a splice above the reader drifted the
full 100px row because the anchor was named one row off.

- heightSyncAnchor: a `/clear` in the same session priced the next
  conversation's first row from the cleared transcript's mean (500 instead of
  the flat 80 estimate); dropping the head-paging exclusion retired a paged-out
  prefix and collapsed the region above from 10000 to 1600; and a prepend that
  regroups the top row away kept its 900 in the mean, pricing the transcript at
  19600 instead of 12000, because that departure satisfies head paging's other
  two properties while growing the count; and a filtered-out prefix in a
  consumer that cannot page kept pricing the survivors at 10000 instead of
  falling back to the flat estimate at 1600.

Virtualizer family 211/211 green (anchorRestore, heightSyncAnchor, integration,
layoutShrink, observerBackfill, postStreamLurch, prependAnchor, railCollapse,
spacerLurch, viewportResize, UseVirtualChatCoverage, HeightCache,
HeightCache.multiInstance, heightOwner, ScrollAnchorCache), plus the 13 other
suites that import the virtualizer. No existing assertion weakened.

Fixes #6076
@iamwhatever
iamwhatever force-pushed the fix/streaming-ghost-row-jitter-6076 branch from d1eb59b to ce60c8a Compare September 2, 2026 01:05
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 2, 2026
@iamwhatever
iamwhatever enabled auto-merge (squash) September 2, 2026 05:25
@iamwhatever
iamwhatever merged commit 7eb5d8f into main Sep 2, 2026
104 of 108 checks passed
@iamwhatever
iamwhatever deleted the fix/streaming-ghost-row-jitter-6076 branch September 2, 2026 05:28
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 2, 2026
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.

Streaming text jitter: "thinking" ghost lines push already-rendered content up and down

2 participants