fix: stop the chat transcript moving on its own - #8574
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Behavioral scroll fixes plus a shape-true skeleton and a properly gated Developer debug tab; every new surface matches product patterns and states are bounded with graceful give-ups. Suggestions
[UX-REVIEWED] c6fc3e4 |
Design Review (Fable 5) — ✅ PASSDesign-level review of All key surfaces check out: the windowing redesign (coverage verified after the response instead of pre-purchased headroom, streaming exemption retired only after Design-Verdict: PASS One coherent root cause (state-skew in scroll decisions) fixed at the cause, every trade-off disclosed, dev tooling gated to zero cost when off. [DESIGN-REVIEWED] c6fc3e4 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS Every fix sits at cause level with a measured harm; the one soft spot is a new plural-named "Debug tools" tab shipping exactly one toggle. What this change shipsIntent: stop the chat transcript loading, growing, and losing the reader's place on its own — a FIX, with one declared instrument riding along.
WatchThe Debug tools tab is a generalized container with one entry: 1 toggle ships in it ( SubtractionsDefer the [FIRST-PRINCIPLES-REVIEWED] c6fc3e4 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates are low-confidence by the discovery pass's own admission, and each dissolves under falsification. Candidate 2 ( Candidate 1 ( No new grounded defect at the 80+ bar surfaced while falsifying these in the two changed files. No findings. [OPUS-REVIEWED] c6fc3e4 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
2c10def to
b33b977
Compare
b33b977 to
a667a95
Compare
a667a95 to
b830d6f
Compare
b830d6f to
698f07a
Compare
698f07a to
a7c685b
Compare
d70ce4f to
f65cd78
Compare
Disposition on
|
First Principles disposition on
|
| claim | measured |
|---|---|
DebugToolsTab.tsx holds one SettingsToggle |
1 — correct |
FeaturePreviewsTab.tsx has 3 cards on the same SettingsCard/SettingsToggle shape |
3 — correct |
debugTools.* keys span 14 locale files |
14 — correct |
scrollInspector.ts is statically imported by 4 product modules besides the tab |
chatSlice.ts, useVirtualChat.ts, ScrollAnchorCache.ts, ChatPage.tsx — correct |
Correcting myself: I first grepped FeaturePreviewsTab.tsx for safeGetItem/safeSetItem,
got zero, and was about to argue the localStorage shape is not shared. That grep was too
narrow. The persistence is one module away in utils/previewFlags.ts, which does import
safeGetItem/safeSetItem, and it already fires a window CustomEvent
(PREVIEW_FLAG_EVENT = 'mc-preview-flag-changed') so a listener updates in the same tick —
which is exactly the mechanism the inspector needs, since the overlay lives outside React by
design. So the review's mechanical claim holds: mechanically, a card is a card.
What still argues against the move, from that module's own docstring
previewFlags.ts documents a narrower contract than "a place for client-only toggles":
Preview flags — local, per-device opt-ins for surfaces that ship in the bundle but are
NOT ready to be released.
Retiring a flag is the goal, not an afterthought: when the surface is polished, delete its
previewFlagfrom the registry entry and its card from Developer > Feature Previews.
The scroll inspector is neither unfinished nor destined for release. It is a permanent
diagnostic that should stay off for every normal user forever, so the registry's retirement
rule — the thing that keeps that list from growing without bound — would never apply to it,
and the nav rail's preview-surface handling would be reasoning about a developer overlay as a
product surface awaiting launch.
So the trade is: one tab + 14 locale key groups + a feature-map row for a single toggle
against a permanent instrument living in a registry whose contract is "temporary gate on an
unreleased surface". Neither side dominates, and the review is right that the
preview-vs-instrument distinction is semantic — it is a contract, not a mechanism.
Disposition
Left as-is on this SHA, and handed to the repository owner rather than decided here. Two
reasons, both procedural rather than a defence of the current shape:
- CONCERNS is advisory, and this is a scope/taxonomy judgment with a real cost either way —
the category that belongs to a human, not to a bot round. - A push voids every SHA-scoped verdict. UX, Design and Opus 4.8 all read clean on
f65cd780band GPT 5.6 is mid-run on it; spending that on an advisory restructuring —
which would also touch 14 locale files, the exact surface with a zero-tolerance i18n
gate — is a poor trade to make unasked.
If the owner prefers the subtraction, it is a contained change: delete DebugToolsTab.tsx,
drop the debug-tools row from buildTabs() in DeveloperPage.tsx, remove the feature-map
row, delete the pages.developerPage.tabs.debugTools.* keys from all 14 locales, and add one
card to FeaturePreviewsTab.tsx — either as a real PREVIEW_* registry entry (accepting the
contract stretch) or keeping its own key and event (accepting the first non-registry card in
that tab). I would take the second, so the registry's retirement rule stays true of every
entry in it.
GPT 5.6 disposition on
|
Three of the five red lanes are pre-existing
|
Four defects in the chat transcript's scrolling this tool was built to find were reproducible only on a real phone: jsdom's getBoundingClientRect is degenerate, so the settle loop that corrects a restored reading position has a body that is structurally unreachable there. Reasoning from the code produced self-consistent stories instead of answers; a log line naming the writer produced answers. The inspector overlays the transcript with live geometry (distance to the end, row count, loaded vs server message count, scroll extent) and a rolling log of the decisions that move the reader, plus two sticky lines naming how the last LEAVE and the last ENTRY resolved the saved position -- those happen at the start of a switch and are the first to scroll out of an 8-line window, which on a phone means they were never visible. Off is zero cost: a module-level flag is read first by every entry point, so disabled means no element, no interval, and nothing retained. The only residue is two idle listeners, which is what lets the toggle work without a reload. It lives in the Developer page behind Developer Mode rather than in Settings, because gen-settings-registry.mjs scans pages/settings/ and would index the toggle into Settings search -- the reason FeaturePreviewsTab documents for sitting there too.
f65cd78 to
2cfe53d
Compare
GPT 5.6 blocking findings — fixed on
|
| mutation | result |
|---|---|
drop isDurableRow from the comparison |
6 tests red |
decline over cached.length instead of the comparable count |
1 test red |
remove permission from CLIENT_ONLY_ROLES |
2 tests red |
The first probe of this reported all three GREEN. That was the probe lying, not the guards
failing — it parsed vitest's summary with a regex that did not survive the ANSI escapes, so a
red run read as no match. Re-run with the escapes stripped, and each mutation reds exactly the
cases it should. Worth stating because a green mutation is normally a signal that a test is
worthless, and here it was a signal that the measuring instrument was.
2cfe53d to
bf867e0
Compare
GPT 5.6 on
|
|
/ai-review override gpt bf867e0: a wire-supplied Recording the evidence for the override, having traced the finding's premise through the backend rather than answering it from the frontend comment I cited last round (which said the
Every The single path where a whole
So the id-space collision this finding needs is already refused at the boundary, by design, And the suggested remedy would reintroduce a defect this PR fixes. "Corroborate IDs with The residual — a server stamping two persisted rows with one id — is a backend invariant, not The non-blocking |
Human judgment recorded@buluoray marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
GPT 5.6 on
|
A reader on a phone saw history load itself on a session switch, a saved reading position land somewhere different each time, and a transcript that had 300 messages hold 6,000. One shape underneath: a decision computed for one state, applied in another. Each cause below was reproduced on a device and named by the writer the inspector caught doing it. History nobody asked for: - A switch asked for `cached + one page` as coverage headroom, but the server window is anchored at the NEWEST row and extends backward, so every spare row is a row of older history -- and the next revisit measured the cache it had just grown, ratcheting a page per switch to the handler ceiling. Coverage is verified AFTER the response, so the headroom bought nothing the retry did not already cover. It asks for exactly what the tab holds. - A slot mid-turn was exempt from that bound entirely, on the same pre-purchase argument, which applied the unbounded shape to the commonest switch there is: 303 loaded messages became 6,265 in one step, 293,000px of transcript, and eventually an OOM-killed tab. - The retained server count refused every RUNNING response, which manufactured the absence its own coverage check then read as "assume a hole" -- so a slot that streams for most of its life took the unbounded path forever. Only an UNBOUNDED read counts raw rows; a bounded one is collapsed by the handler before it slices, so its count is comparable and is kept. The retry carries the bounded count forward instead of discarding the only comparable one it had. - Entry no longer inherits authorization. The refs recording reader intent are written on a real wheel/touchmove over the transcript by a listener that is not keyed on the slot, so a gesture in the session you left authorized the doors in the one you opened; they clear on a slot change. The walk poll's authorization was a latch that could only turn ON -- one touch authorized it for the rest of the mount -- and its page budget was an effect-local variable reissued on every re-creation. It now reads the same expiring window the sentinel door does. The pinned-jump walk incremented a counter it never compared to anything. The reading position: - The persisted anchor stored a per-render key, which ChatPage documents as valid only inside the render that produced it. It stores the index-free stable id -- in all four places, including the settle comparison that aborted at frame 0 on a vocabulary mismatch. - Neither end of a row is stable alone: appends rename its tail, an older page landing regroups messages into its head and renames its lead. Both are persisted and either resolves. - Convergence required the whole transcript to stop growing, but the cause of the corrections is height arriving ABOVE the anchor, which appends below it never touch -- so during a live turn convergence was unreachable and every restore burned its entire budget with the skeleton up. - Four mechanisms wrote the scroller during one landing, each undoing part of the last (+261px of drift). They hold a row where it was, which is the settle's job, so they stand down while it is ACTIVELY correcting -- and take over when it goes blind, because a settle that cannot see its row corrects nothing while still holding its gate. - An automatic bottom pin decided on pre-restore geometry and applied its write a frame later, landing a reader who had left 24,600px from the end at the end. It re-checks when it applies, not only when it is decided. - The idle branch of evaluateAutoPin released follow whenever the reader was not within the bottom epsilon, without asking who moved: a late image or widget growing the transcript under a reader parked at the bottom made the rule that exists to catch a scroll-up release follow instead. It compares the live scrollTop against our own last write first, so content moving is a pin and a real scroll-up is still a release. Automatic actions no longer authorize themselves: the walk reads an expiring window, the anchor save revokes the gesture a restore borrowed, and the settle aborts on hard input rather than on the scroll events its own writes produce. The placeholder that covers all of this: - One placeholder now covers both waits. Fetching a slot showed a centred spinner and restoring a reading position showed grey bars, so two readings of the same fact -- the transcript is not ready -- looked like different events. A spinner also only says "wait", where a skeleton previews the shape that is coming, which is the entire reason to prefer one. - It is shaped like a transcript: full-width assistant blocks whose last line is short, alternating with narrower right-aligned user bubbles, the sweep staggered per line so it reads as one wave travelling down them. Six equal full-width bars preview a table, and a reader watching one turn into a conversation has been told nothing by it. - `.skeleton` pointed at `@keyframes shimmer`, which translates its TARGET -- right for `.animate-shimmer` (a highlight element on a track) and wrong here: every bar in the app slid sideways by its own width instead of passing a highlight across itself. It has its own keyframes now, moving a highlight pseudo-element, so a sweep costs a compositor transform rather than repainting a gradient every frame -- which matters most exactly here, since a skeleton appears when the main thread is busiest measuring the rows it stands in for. Every other skeleton in the app is fixed by the same change. - The reduced-motion rule that zeroes animation-DELAY (the global one covers duration only) now names the skeleton, because a stagger left in place there holds bars dark in sequence instead of stopping.
|
| path | at merge base | on ff23c7d87 |
|---|---|---|
| top sentinel | sawInput: !vGetFollowRef.current() — any scroll-up authorized it |
expiring gesture window |
| the walk | one-way sawRealInputRef, fed by the same two events only |
same window |
So the sentinel half is this PR's regression, as stated. The walk half was already
keyboard-blind at the base — the latch it used had the identical two-event vocabulary. Design
attributes the whole thing to this PR; half of it predates it. That does not reduce the fix,
which repairs both halves at once, but the record should be accurate.
The remedy is the first one Design offers, not the second. Restoring !follow is not
available: !follow is true after any scroll-up including our own, and that is precisely the
self-authorization this PR removes. Nor is "state the manual-bar fallback as the intended
contract" acceptable — keyboard navigation is the accessibility path, and quietly demoting it to
a manual button is a functional loss, not a contract.
So the vocabulary widens to four events, bound to the scroller:
el?.addEventListener('wheel', noteInput, { passive: true })
el?.addEventListener('touchmove', noteInput, { passive: true })
el?.addEventListener('keydown', noteInput, { passive: true })
el?.addEventListener('pointerdown', noteInput, { passive: true })This does not loosen the window's premise by even a little. The premise is that writing
scrollTop cannot authorize the next fetch, and a scrollTop write fires none of these four —
only scroll, which is still excluded and still pinned by the existing assertion. What changes
is only the set of humans the window can hear.
keydown is bound to el, deliberately not to document: a document-level keydown would let
typing in the composer authorize a history fetch, which is the same category of mistake as
reading our own scroll write as consent. Teardown removes both new listeners alongside the
original two.
Verification
tsc -b0 errors;eslint --max-warnings 0on both changed files, 0 findings.- 90 test files / 913 tests green across
ChatPage.*,olderHistory*,pagination*,
useVirtualChat.*,ScrollAnchorCacheandchatPins. - The existing design-rationale test was extended, not rewritten: its property (the stamp is
refreshed by real input, never byscroll) is untouched and still asserted; the vocabulary it
pins now requires all four events, with the reason recorded inline. Its 400-char scan window
was widened because the new registrations sit past it. - Two mutations, each reddening only what it should: deleting the
keydownregistration reds 2
tests; re-bindingkeydowntodocumentreds exactly the scope test.
Disclosed in the PR body under One behaviour change worth naming — it is a deliberate
narrowing of who may authorize, not a bug fix, and it belongs in the description rather than
only in a comment.
Still the user's call, unchanged
- First Principles CONCERNS — the one-toggle
debug-toolstab (dispositioned in
#5551253575): keep
the tab, or move the card intoFeaturePreviewsTab.tsx. The mechanical claim holds; what
argues against the move is that module's own retirement contract, which a permanent diagnostic
never satisfies. - The
forcepin2 Hz no-op, proven pre-existing atc791f0f1d.
A reader on a phone saw history load itself on a session switch, a saved reading
position land somewhere different each time, and a session holding 300 messages
come back holding 6,265. This is that whole family, plus the tool that found it.
One shape underneath all of it: a decision computed for one state, applied in
another. Each cause below was reproduced on a real device and named by the
writer the inspector caught doing it -- not inferred from reading the code, which
produced self-consistent stories rather than answers for several rounds.
Why a dev tool ships with the fix
Four of these are structurally invisible to the test suite. jsdom's
getBoundingClientRectreturns zeros, so the settle loop that re-lands arestored reading position has a body that never executes there: a tolerance
finer than the device pixel grid, a frame budget that expired before measurement
began, a cancelled
requestAnimationFrame, and a hydration deadline that gotharder to meet the more was loaded. All four were found by putting a live readout
on the phone.
The inspector overlays the transcript with its geometry and a rolling log of the
decisions that move the reader, plus two sticky lines naming how the last LEAVE
and the last ENTRY resolved the saved position -- those happen at the start of a
switch and are the first to scroll out of an 8-line window, which on a phone
means they were never visible. Off is zero cost: a module-level flag is read
first by every entry point, so disabled means no element, no interval, nothing
retained.
The turning point was giving each of the fourteen code paths that write this
scroller a name in the log. A position landing somewhere nobody intended had been
unattributable; one capture then read:
Same decisecond. Culprit, direction and magnitude, in two lines.
History nobody asked for
cached + one pageas coverageheadroom, but the server window is anchored at the NEWEST row and extends
backward, so every spare row is a row of older history -- and the next revisit
measured the cache it had just grown, one page per switch to the handler
ceiling. Coverage is verified AFTER the response, so the headroom bought
nothing the retry did not already cover.
argument, which applied the unbounded shape to the commonest switch there is:
303 loaded messages became 6,265 in one step, ~293,000px of transcript, and
eventually an OOM-killed tab. Run state is no longer an input to the window at
all -- an exemption that can be re-expressed by passing a flag is one that
grows back.
manufactured the absence its own coverage check then read as "assume a hole",
so a slot that streams for most of its life took the unbounded path forever.
Only an UNBOUNDED read counts raw rows; a bounded one is collapsed by the
handler before it slices, so its count is comparable and is kept. The retry
carries the bounded count forward instead of discarding the only comparable one
it had.
queued,streaming,thinkingandpermissionrows exist only in this client, so a bounded windowcannot contain one however wide it is asked to be -- and a cached row the window
can never hold is not a hole that a bigger read closes. It read as a shortfall
that never goes away, so every switch into a slot holding a queued message or a
permission card refetched the whole transcript. The check now reads the same
isDurableRowpredicate two other consumers in that file already share; anarrower home-grown test caught
streamingand missed the other three.latch that could only turn ON -- one touch authorized it for the rest of the
mount -- and its page budget was an effect-local variable reissued on every
re-creation. It reads the same expiring window the sentinel door does. The
pinned-jump walk incremented a counter it never compared to anything.
The reading position
which
ChatPagedocuments as valid only inside the render that produced it. Itstores the index-free stable id, in all four places -- including the settle
comparison that aborted at frame 0 on the mismatch.
page landing regroups messages into its head and renames its lead. A switch
back into a live turn does both at once, which is why one identity always
missed. Both are persisted and either resolves.
transcript to stop growing, but the cause of the corrections is height arriving
ABOVE the anchor, which appends below it never touch. Every restore burned its
entire budget with the skeleton up, however early it had really landed.
the last (+261px of drift). They hold a row where it was, which is the settle's
job, so they stand down while it is ACTIVELY correcting -- and take over when it
goes blind, because a settle that cannot see its row corrects nothing while
still holding its gate.
write a frame later, landing a reader who had left 24,600px from the end at the
end. It re-checks when it applies, not only when it is decided.
evaluateAutoPin's idle branch released follow without asking who moved, soa late image growing the transcript under a reader parked at the bottom made
the rule that exists to catch a scroll-up release follow instead.
Automatic actions no longer authorize themselves: the walk reads an expiring
window, the anchor save revokes the gesture a restore borrowed, and the settle
aborts on hard input rather than on the scroll events its own writes produce.
The placeholder that covers all of it
One placeholder now covers both waits. Fetching a slot showed a centred spinner
and restoring a position showed grey bars, so two readings of the same fact --
the transcript is not ready -- looked like different events. It is shaped like a
transcript (assistant blocks with a short last line, alternating with narrower
right-aligned user bubbles, the sweep staggered per line so it reads as one wave
travelling down them), because six equal full-width bars preview a table.
.skeletonalso pointed at@keyframes shimmer, which translates its TARGET --right for
.animate-shimmer, wrong here: every skeleton bar in the app slidsideways by its own width instead of passing a highlight across itself. It has
its own keyframes now, moving a highlight pseudo-element, so a sweep costs a
compositor transform rather than a gradient repaint every frame -- which matters
most exactly here, since a skeleton appears when the main thread is busiest
measuring the rows it stands in for. Every other skeleton in the app is fixed by
the same change.
Device verification
Same session, scrolled up mid-history, switched away, switched back:
y=8972y=8972Byte-identical, not merely close, and repeatable across sessions. The settle
converges at frame 1--2 with a 0.0--0.9px residual during a live turn, where it
previously ran 34 frames and gave up.
Tests
Every fix is pinned, and every guard was mutation-verified -- the source mutated,
the run confirmed red, the mutation reverted. Two invariants have no runtime
surface jsdom can reach (a restore must drop the capture it supersedes; the
bottom pin must re-check ownership at APPLY time, not only when decided), so
those are guarded against the source text, the way
FollowController.test.tsalready reads its own source.
One mutation stayed green and is reported as such rather than papered over:
devWatchScroller's gate is belt-and-braces behindensureHost()'s own, and itsonly unique effect has no observable surface.
Ran:
tsc -b(0), targetedeslint --max-warnings 0(0), the 15 affected testfiles (185 passing),
lint:i18nandi18n:check(both 0). The full backendsuite was not run -- this change is frontend-only.
Not in scope, recorded
Scrolling up through never-measured history accelerates in places, because
unmeasured rows are priced at one running mean while the content is bimodal (the
height index's own comment: a code-fenced row is often 5--30x the mean). That is
pre-existing, needs a better per-row estimate, and belongs in the measurement
path rather than stacked on this.
Screenshots
The new surface: Developer → Debug tools, behind Developer Mode. Off is the
default, and off means the module holds no element, no interval and nothing
retained -- the two states are shown because that contract is the reason it can
ship enabled-by-toggle rather than behind a build flag.
Scroll inspector toggled on
Captured on an isolated pod built from this branch (
kirocrew pod up), not thelive gateway; the pod's HOME was verified gone afterwards. The overlay's own
output is quoted throughout this description -- those readouts are what this tool
produces on a real phone, which is where every defect above was found.
Removed outright: the idle history prefetch
ChatPage.tsxhad anIdle history prefetch (feeds the measure farm)effect. While thereader was idle and everything loaded was measured, it pulled the next older page so the
measure farm had geometry to price, repeating until the whole session was measured.
It is deleted, not adapted, and the reason is the rule this PR is built on: it issued
loadOlderMessageswith no reader gesture behind it. That is the definition of historyloading itself, and it cannot be reconciled with the expiring-gesture window the sentinel
and the walk now share -- an idle prefetch fires precisely when the reader has expressed
no intent, so authorizing it by a real gesture is a contradiction rather than a tightening.
The trade-off, stated: a first back-scroll now crosses estimate territory that the prefetch
used to have measured. That is the same acceleration symptom recorded under "Not in scope"
below, and it is accepted here rather than paid for with unasked loading. The real fix is
to price unmeasured rows better, not to load them early.
Pattern harvest
Rule candidate: semgrep (frontend)
Pattern: a scroll-position correction applied without re-reading the state it was computed against
Every defect in this PR is one shape, seventeen times over: a decision computed
for one state and applied in another. Named concretely so it is checkable:
cached + headroomwhere the window's own geometry makesheadroom mean "older history".
sawRealInputRef), so a gestureauthorizes every later event.
re-creates.
rAF.One behaviour change worth naming, because it is not a bug fix
Replacing the top sentinel's
sawInput: !vGetFollowRef.current()with the expiringreal-gesture window narrowed who can authorize automatic older history:
!followwas true after ANY scroll-up, our own included, which is what let the ratchet run —
but it also meant a keyboard reader (PgUp/Home/space) or a scrollbar-drag reader
authorized it for free. The window is fed by real input events, and had those been
wheel/touchmovealone, exactly those readers would have lost automatic historyand been left with the manual Earlier bar.
So the vocabulary is four events, bound to the SCROLLER:
wheel,touchmove,keydown,pointerdown. None of the four can be produced by writingscrollTop,so the premise — an automatic scroll cannot authorize itself — is unchanged, while
keyboard navigation keeps working.
keydownis deliberately not ondocument:typing in the composer is not an intent to read history.
The generalizable half is narrower than "be careful": a correction that reads
geometry must re-read it at the moment it writes, and a permission that can be
granted must be able to expire. Both are mechanically detectable in this
codebase's shape -- a
writeScrollTopinside arequestAnimationFramewhoseguard was evaluated outside it, and a
Refthat is only ever assignedtrue.A third, weaker candidate is a lint for a
@keyframesname shared by rules thatanimate different properties:
.skeletonpointed at keyframes written totranslate their target, which silently slid every placeholder bar sideways for as
long as it existed.
Not generalizable, and left as one-offs: the pinned-jump counter that was
incremented and never compared, and the tolerance chosen finer than the device
pixel grid.