fix(chat): load older history only when the reader is at rest - #9130
fix(chat): load older history only when the reader is at rest#9130buluoray wants to merge 2 commits into
Conversation
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence gathered. The blind read couldn't open the sole artifact (4MB GIF, same limit I hit), the diff's user-visible changes are the silent-at-rest history load, the removed loading overlay, the withheld chevron on no-op diff rows, and the declared expand-animation regression. Composing the review. UX-Verdict: CONCERNS Solid scroll-reading fixes, but the only visual evidence is a 4MB GIF nobody's blind read could open, and one declared animation regression ships app-wide. Watch
Evidence gaps
[UX-REVIEWED] c829837 |
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix with device-measured evidence: stillness gates the fetch at both ends, drift accounting stops self-compensation, and every trade-off is declared and argued. The riskiest pieces were checked and hold up: the removed abort-on-scroll reappears at the new single gate chain (start refuses while moving, motion kills in-flight), the untestable capture-consume race is answered by extracting the arithmetic into a directly-tested pure function rather than by weakened harness tests, the WarmSwap removal is behaviorally coupled to this PR's press-rooted reprice rule (not scope creep), and the payload change that genuinely didn't belong was already split to #9156. Everything here is frontend behavior that reverts cleanly; the acknowledged residual risks (landing-buffer removal re-verified only on device, expand-animation snap) are declared in the PR with their fallback fixes recorded next to the code. [DESIGN-REVIEWED] c829837 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification is done: the First-Principles-Verdict: CONCERNS The zero-lead gate ships while pagination.ts still documents, defaults, and tests the lead it bypasses — two pins now record opposite decisions. Not justified as shipped
What this change shipsIntent: make reading back through long chat history on a phone stop jumping and stop loading pages the reader never asked for — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c829837 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe removals are clean — The candidate list was empty. Working through the diff, the production-affecting changes (FileChangeChips content-pinning and No findings. [OPUS-REVIEWED] c829837 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Both GPT 5.6 findings are about code this PR does not contain. They belong to Evidence, from this branch's worktree:
So this is a diff-scope artifact rather than a security regression: the session The underlying cause is real and worth fixing anyway — a stale base makes every /ai-review override gpt 6a976bb: both findings cite #7573 code absent from this branch (grep returns 0 for sessionControlKey and MAX_SESSION_CONTROLS_PER_APP; apps/manifest.py is not among the PR's 46 files) — a stale-base two-dot diff artifact, and a rebase onto current main is queued. |
|
Disposition for the three CONCERNS verdicts. Each finding was checked against Accepted — real, and mine1. Deleting the spinner left a press with no feedback. (UX) Correct, and worse than the review states: I also deleted the string it used, so 2. A capped-patch row opened from a minimal chip degrades to a plain file view. Verified at <MinimalChip … onClick={() => onOpenDiff?.(fc.path, fc.after ?? '', fc.before ?? '')} />A patch-only row has no 3. Verified: 4. Verified: one production caller ( 5. Fair. It is a position-writing mechanism in a PR whose own comments say one Accepted — cause identified, needs a force-push6. Half the diff is #7573. (First Principles item 10, and the root cause of both A stale base. A rebase onto current Needs a decision, not a guess7. The wire-format change does not share the "cannot be split" rationale. This is correct and I will not argue it. Splitting it out is a scope decision for the author, so it is in front of them 8. Is 8KB the intended UX cut? (Design) Design is right that Not acceptedThe two GPT 5.6 blocking findings, rebutted with evidence in the comment above: |
|
The three new GPT 5.6 findings are accepted — all real, all mine, and the F3 — symlink TOCTOU in the snapshot read
validated = validate_file_path(path)
...
return p.read_text(encoding="utf-8", errors="replace")
F2 — unbounded read and patch generation on the event loopSame asymmetry. The sibling gates on size before reading and re-checks after F1 — a change past the cap becomes invisibleThe gate is if (before_full is not None and after_full is not None
and entry["before"] == entry["after"] # capped prefixes identical
and before_full != after_full):
entry["patch"] = _unified_patch(...)My own comment above it claims this is "computed only for exactly that case" — The condition was written from the wrong question. It asks "are the prefixes Where these get fixed is a scope question, not a technical oneAll three are in the payload-shrink component, which Design Review independently So the author decides whether they are fixed here or in the split-out PR, and I am No override is requested for any of these three. |
6a976bb to
229774e
Compare
2ca1650 to
e94f382
Compare
Two backend failures here are inherited from
|
| ref | security.py defines _dir_holds_sensitive_leaf |
test_security.py references it |
|---|---|---|
origin/main (d4cb9afc7) |
0 | 2 |
d4cb9afc7 — refactor(security): move the path fence to the layer that can hold it (#9089).
Reproduced on a detached worktree at origin/main, no branch of mine involved:
$ git worktree add --detach /tmp/probe origin/main
$ python -m pytest test/test_security.py
1 failed, 1500 passed, 1 skipped
FAILED test/test_security.py::TestIsSensitiveBashCommand::
test_chained_cd_expansions_do_not_blow_up_the_gate - AttributeError
This PR's diff touches zero files matching security or hooks.py
(git diff --name-only origin/main...HEAD → 0 matches), and since a PR is tested
as a merge commit, the breakage arrives through the merge.
2. test_terminal_handler.py::TestTerminalWsIntegration::test_ws_bash_restores_an_inherited_prompt_command@pty_integration
assert tail.index(b"PREV_RAN") < tail.index(b"PC''NOW")
E assert 93 < 58
An ordering assertion over pty output. test_terminal_handler.py passes in full on
a pristine origin/main worktree locally (340 passed, 1 skipped), and the case
carries an @pty_integration marker, so it only executes where a real pty is
allocated. This PR touches no terminal, pty, or websocket file. Not rerun — the
authority I am operating under forbids rerunning a job to dodge a failure, and
whether this is order-dependence in CI or a genuine main defect needs a
maintainer's read rather than a green retry.
Coverage Gate is a cascade, not a third failure
It fails closed because an upstream backend job did not succeed
(::error::backend-test=... -- failing closed). The per-file frontend floor it was
previously failing on — src/lib/scrollQuiet.ts at 29.4% — is resolved: that module
is deleted in the current head, since this PR removed its last production consumer
and the gate's own message says to add tests rather than extend the baseline. Its
device-measured rationale moved next to the mechanism that took over the job.
e94f382 to
b464e39
Compare
Disposition for the BLOCKThe blocker is right, and it is the sharpest kind of finding: the description Blocker — accepted, fixed in the descriptionDefect 3 was claimed as shipped and is not in this diff. Correct. That change is Worse than stated, in one respect the review could not check: the Tests section Both are corrected: section 3 now states plainly that the shrink is not in this Subtractions — both accepted, both verified first
One caveat worth recording for the next reader: the overlay has two The test that pinned this was rewritten rather than dropped, because its intent expect(thunkBody()).toMatch(/devOlderSpans\(netMs\)/)
expect(thunkBody()).not.toMatch(/devOlderSpans\(netMs,/)Mutation-verified: restoring The Watch items — now declared rather than removedItems 7–10 were real fixes riding undeclared, and that was a defect in the
Also in this push
Not addressed hereTwo backend checks are red from |
The three remaining red checks are one
|
| Check | Cause |
|---|---|
Backend Tests (3.12, 4) |
test_security.py::test_chained_cd_expansions_do_not_blow_up_the_gate |
Backend Tests (Windows) (3) |
the same test — Windows shards split differently, so it lands in shard 3 |
Coverage Gate |
cascade: ::error::backend-test=failure -- failing closed |
The defect is in main, not in either branch. d4cb9afc7 — refactor(security):
move the path fence to the layer that can hold it (#9089) — removed
_dir_holds_sensitive_leaf from security.py and left the test monkeypatching it:
| ref | security.py defines it |
test_security.py references it |
|---|---|---|
origin/main |
0 | 2 |
Reproduced on a detached worktree at origin/main with nothing of mine present:
$ git worktree add --detach /tmp/probe origin/main
$ python -m pytest test/test_security.py
1 failed, 1500 passed, 1 skipped
FAILED …::test_chained_cd_expansions_do_not_blow_up_the_gate - AttributeError
And the cross-PR check, which is the part a single-PR view cannot give you:
- fix(chat): load older history only when the reader is at rest #9130 (scroll + diff-row) —
git diff --name-only origin/main...HEADmatching
security|hooks.py: 0 files. It contains no Python whatsoever. - perf(chat): send an oversized diff pair as a capped patch #9156 (payload shrink) — same test, different shard number.
Two unrelated branches cannot independently break one security test they never
touch. Since a PR is built as a merge commit, both inherit it from the base.
Not rerun and not patched: rerunning would only re-import the same missing symbol,
and fixing it means editing security.py or its test, which is outside both PRs'
scope and belongs to whoever landed #9089. Flagging for a maintainer.
The Coverage Gate entry needs no separate action — it fails closed on the backend
job rather than on a coverage floor, and the per-file floor it was previously
failing (src/lib/scrollQuiet.ts, 29.4%) is resolved in the current head.
Automatic paging loaded while the reader was still climbing, and the affordance counted as visible two screens before it was, so history arrived unasked and every landing moved the transcript under them. It now requires the bar genuinely on screen and the scroller quiet, and motion aborts a page already in flight -- the gate is checked when a fetch begins, so one half refuses to start and the other refuses to finish. The anchor correction stops creating the displacement it repairs. It refuses when the row is already where it was captured, drops a capture that straddles a restore giving up, and splits the scroll drift since capture between the reader and our own writes instead of crediting all of it to a finger -- a concurrent reprice was being compensated twice, a 1px displacement authorising a 267px write. The landing buffer's own module goes with it. Holding a fetched page until the scroller fell quiet was 1.95s of a 2.1s wait against a 0.16s request, and the trigger-side gate replaces it, so the helper had no reader left -- yet every user scroll still stamped a timestamp nothing consumed, and the file sat at 29% under the coverage floor. The device measurement that retired it is recorded where the decision now lives rather than in a module nothing calls. A diff row also keeps the contents it opened with when the payload is replaced under it, survives a warm swap instead of collapsing, and withholds its disclosure control when there is nothing to disclose. A dev-only scroll inspector, off by default and asserted to cost nothing while off, carries the device readings all of this is derived from. Two instruments that described mechanisms this PR removed are gone with them: the paging span's hold argument, which could only read zero once the wait was deleted and so was a constant rather than a measurement, and a comment claiming every measurement teaches a content-aware estimator, which no longer exists anywhere.
A desktop recording of a reader climbing a 500-message transcript: three pages land while the scroller is at rest and the content already on screen stays put. Recorded against an isolated pod running this branch, driven by wheel events rather than scrollTop assignments so the clip exercises the gate's own path.
b464e39 to
c829837
Compare
|
Rebased onto main Gates run locally in a scratch worktree:
Please review the rebase result. Because a maintainer pushed last, the repo's last-push rule means a second approver is needed. Reply here if anything looks wrong. |
|
@buluoray This PR and #9156 overlap on All three of that file's edits appear in both PRs in near-identical text: Suggested order: land this PR first, since #9156 still has an open blocking review finding awaiting your design decision. When #9156 rebases, drop this PR's copy of the three hunks and keep its patch-aware version. The Two things independent of the overlap. Please rebase onto current main; the branch is 99 commits behind, and that stale base is what produced the two rebutted GPT findings about #7573 code missing from the branch. Also, the diff commits a 4,037,906-byte GIF under Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
Reading back through a long chat transcript on a phone, two things went wrong.
History arrived unasked: pages loaded while the reader was still climbing, and the
"Load earlier messages" control counted as visible while it was still two screens
above the viewport — so the reader never saw the thing that offered the load.
And each landing moved the transcript under them. The position always ended up
right (read back after the write, the anchor row lands within a pixel of where it
was captured) while the motion was wrong, so every attempt to make the
correction more accurate failed: accuracy was never what was broken.
Why it matters
This is the reading path for every long conversation, and the failure is on the
surface a reader spends the most time on. Losing your place mid-sentence while
scrolling back is not a cosmetic defect — it makes long history effectively
unreadable on a phone, and it fires on every page, not occasionally.
What changed (motivation → approach → change)
Symptom → cause → change, for each of three defects.
1. History loaded while the reader was moving
The affordance check was passed a 2000px lead, so the bar counted as on screen two
screens early. The timing gate required a scroll event within the last 1500ms —
i.e. it deliberately loaded during the climb.
A programmatic
scrollTopwrite during an iOS fling perturbs a curve thecompositor owns, not the main thread, so the write cannot be made invisible while
the surface is moving. Rest is the only state where a landing cannot be felt — the
clearest evidence being that at the very top of the transcript loading never
bounces, and the very top is exactly where a fling cannot persist.
Lead is now zero, and the gate is inverted: 100ms of quiet. Motion also aborts a
page already in flight, because the gate is checked when a fetch begins — a
reader at rest who flings during the round trip would otherwise take the landing
mid-gesture. One half refuses to start, the other refuses to finish.
100ms is matrix-react-sdk's number for the same problem, reached independently.
Every estimate-based virtualizer that ships a chat list lands here: react-virtuoso
carries an iOS-only glitch report for attempting it live, virtua's README states
the user must release the scroll, TanStack Virtual has a code path named for
deferring the adjustment on iOS.
2. The anchor correction created the displacement it repairs
Three distinct defects, each found from a device reading rather than from code
review.
It corrected rows that had not moved. The write is now gated on the anchor
row's measured on-screen displacement, not only on the derived delta. On one
entry frame the displacement was 0px both times while the delta asked for −1016
and then +1448, and both writes were painted.
It built on an abandoned basis. A restore that gives up re-arms follow and
takes the default placement, which moves
scrollTop— so a capture straddling itis priced against a layout that no longer exists. That capture is now dropped, and
the flag is consumed by the first consume rather than latched, because an
authorization that can only turn on is not an authorization.
It credited its own writes to the reader. The correction isolates the content
term by adding back the scroll drift since capture, on the premise that the drift
is a finger. One frame carried
repriced 3078px in 7writes inside that window,so the drift added back was our own reprice and the same content was compensated
twice — a 1px displacement authorising a 267px write. The drift is now split, with
our own writes accumulated at the one chokepoint every write already passes
through, and read as a difference against the capture rather than as an absolute
(a cumulative counter used absolutely drifts further off the longer a session
stays open, which no short test would surface).
Deliberately not clamping the write to the measured displacement, which looks
equivalent and is not: with content up 8360 and the reader down 500, writing 8360
preserves their scroll and clamping to 7860 erases it.
3. One percent of rows carried 69% of a page's bytes — now #9156, not this PR
A file-change before/after pair ships both full texts, so a large edited file
travels twice at full size. That shrink is real and measured (5.50MB → 1.44MB per
page) but it is not in this diff — it moved out on Design Review's
recommendation, and the section below records the split. No backend file, no
_unified_patch, and no new pytest module is present here; grep either name atthis head and you get nothing.
Kept in this PR is only the diff-row behaviour that shares files with the scroll
work: an open diff row survives a warm swap instead of collapsing and losing the
reader's place.
Tests
tsc -bclean, eslint clean on every changed source file, 1904 frontend testfiles / 30,014 tests green (full suite, because this PR touches shared components
where a local run has already proved insufficient). No backend gate is claimed:
this PR contains no Python.
Every new guard was mutation-verified — the fix removed, inverted, or degraded,
and the test confirmed red before restoring. What each locks in:
anchorCorrection.test.ts(5 → 9 cases): that the scroll drift is split betweenthe reader and our own writes, and that the write total is read as a difference
rather than an absolute. Mutations: not subtracting our writes → 3 red; wrong
sign → 3 red; absolute instead of difference → 2 red; dropping the reader term →
6 red. The five pre-existing cases now pass
capturedWriteSum: 0explicitly,because "nothing else wrote the scroller in this window" is an assumption each
reading was taken under and a default would hide it.
olderHistoryDistanceTrigger.test.ts: zero affordance lead, stillness as thepermission, a floor on the quiet threshold, and that motion aborts an in-flight
page.
scrollInspectorWiring.test.ts: that the write accumulator is fed at the onechokepoint and before the write, and the two conditions that stop the corrector
moving a reader who has not moved.
scrollInspector.test.ts: that the residual is sourced from the corrector ratherthan from the spacer, and that no live line exceeds the width the device shows.
Two of these are source-level pins rather than behavioural tests, and that is a
measured limit rather than a shortcut: a test cannot move the scroller
between an anchor's capture and its consume, because
act(() => rerender(...))flushes layout effects synchronously. Two earlier attempts to test it through the
harness passed with the correction removed and with its sign inverted. The gap is
recorded in the test file.
One i18n key (
pages.chatPage.loading_earlier_messages) is deleted from all 12catalogues rather than left dead, so the dead-key ratchet holds at 28 instead of
being raised to 29.
Manual verification
Every number in this description was read off an iPhone against a dev overlay, not
inferred — including the ones that refuted my own explanations. Three separate
readings each overturned a hypothesis I had already written down:
hold=yeson the device disproved "iOS has no scroll anchoring". WebKit hasshipped it (bug 307734), so the browser is now a second controller here.
spacer 0->0while 8,360px of content arrived proved the residualinstrument was reporting a fake defect on a pixel-perfect landing.
off = −S, notoff = G − Δ: an identity I got wrong, which had me claim twowrites were "pure error" when the reading only showed scrollTop drifting.
Desktop behaviour is recorded below, on a pod running this branch.
Screenshots / video
Desktop, 1440x900, 27s. The pauses are the feature, not padding: the gate wants
the affordance genuinely on screen and the scroller quiet, so a clip that
scrolled continuously would show nothing loading at all.
Three pages land. Measured by the scenario itself, from the scroller:
scrollTopThe reader reaches the top each time, roughly 11k of content arrives above
them, and
scrollTopadvances to match — so the height grows 3.9× while theirposition reading barely moves. That is the compensation doing its job.
Recorded against an isolated pod running this branch, seeded with 500 synthetic
messages of deliberately uneven height: short replies, walls of prose, code
blocks, lists. A transcript of uniform rows is the one case a height-estimating
virtualizer gets right by accident — every estimate is correct, so no correction
is ever needed and a recording of it proves nothing.
Scrolling is driven by
mouse.wheel, not by assigningscrollTop: an assignmentwould bypass the very event path the gate is written against.
recorded from b464e39a450448b246720d68f9312eab6df44f8f · fix/older-history-loads-at-rest · pod kirocrew-wt-diffrow · real server and real paging path, no fixtures or mocksRelated Issues
N/A — found and reproduced from device use rather than from a filed report.
Pattern harvest
Rule candidate: review-prompt
Pattern: a derived figure corroborated only by another figure computed from the
same capture is not corroborated.
This defect class produced three of the failures above and cost most of the time.
deltaandoweddisagreed by only 246px, so they read as confirming each otherwhile both were priced against an abandoned capture — and
res, the differencebetween them, looked small, which is exactly why watching the residual could
never have found it. The same shape appeared in the instrument: a residual derived
from the top spacer agreed with itself across landings while reporting 8,360px of
defect on a landing the corrector measured at zero, because the spacer is a proxy
for "content above the reader" that stops moving once the window reaches the start
of the list.
The rule that falls out: when a correction checks itself, the check must come from
an independent source — for a position, that means a readback from the DOM, not
a second arithmetic path over the same inputs. Worth a review prompt because it is
invisible in a diff (both figures look carefully computed) and it fails toward
confidence rather than toward an error.
Checklist
useVirtualChat.tsnext to the code they governNot in this PR
The above-fold reprice still converges rather than corrects on the landings where
it runs — a run of writes measured at +8, +5, +4, +3, +2, +1, +1 inside 100ms,
which is two controllers settling against each other now that WebKit ships scroll
anchoring and the browser is doing this too. It reads as already-loaded content
shifting slightly instead of standing still. The finding, the readings, and the fix
it points at are recorded in
useVirtualChat.ts: one mechanism corrects theposition at a time, and changing which one deserves its own change with its own
device pass.
Disclosure:
relandConvergedAnchorAdded after First Principles Review correctly noted this ships undeclared.
A restored reader was put at a stated target — row X,
toppx below the viewportedge — so when rows above it are repriced,
scrollTop += deltais the wrong answer:that keeps a reader still relative to content, which is what someone who
scrolled there themselves wants, not someone placed on an absolute mark. This
re-solves the absolute placement instead: read where the row is now, correct by the
difference.
On the "one mechanism corrects the position at a time" invariant this PR leans on —
it upholds it rather than adding a fourth competitor. It returns
trueonly when itowns the correction, and the caller's relative compensation stands down for that
batch; the two are mutually exclusive by construction, and it applies once per batch
rather than on a loop, which is what keeps it out of a tug-of-war with the
compensation it replaces. It declines whenever it cannot be sure — no memory, a
different session, the reader has taken over, the row is unmounted, a degenerate
rect — and the existing behaviour stands.
Fair hit that the description omitted it: a mechanism that writes
scrollTopshouldbe named in a PR whose whole argument is about who is allowed to write
scrollTop.Split: the payload shrink moved out
On Design Review's recommendation, the oversized-diff-pair change is now
#9156 instead of riding here.
Design was right that it shares no file with the scroll work, so this PR's
"cannot be split" rationale never covered it — and bundling them meant reverting a
scroll regression would also revert the transcript wire shape.
The three blocking defects GPT 5.6 found in that component are fixed there, not
deferred: the symlink window in the snapshot read, the unbounded read feeding
difflib, and the gate that let a change past the cap disappear from the UI.
What stays here is the scroll work and the diff-row behaviour it shares files
with: a row keeps the contents it opened with when the payload is replaced
underneath it, survives a warm swap instead of collapsing, and withholds its
disclosure control when there is nothing to disclose.
Riding along, declared
First Principles is right that these were in the diff without being described. They
are named here rather than removed, because each is in a file the scroll work
already changes and each was found while measuring it:
anchor machinery as the scroll fix, same measurement rig.
WarmSwap. This changes everyPierreFilePairconsumer, not only chat — the widest-blast-radius item in thePR. It removes the perceived expand animation, which is a known open
regression, tracked and deliberately not fixed here: restoring
WarmSwaptrades the snap for a blank row, because inside the warm box the surface is
absolute inset-0 … invisibleand windows against a hidden parent-sizedviewport, so it renders no rows and nothing invalidates the measurement on
reveal.
PierreCodealready carries the same exemption for whole-file surfaces.instruments, off by default and asserted to cost nothing while off, carry the
device readings every number in this description comes from.
relandConvergedAnchorwritesscrollTop. In a PR whose own comments sayone mechanism corrects the position at a time, this is a third writer and needs
saying: it is mutually exclusive with the relative compensation it replaces —
the converged path runs instead of that write, not alongside it — so it does not
make a fourth concurrent mechanism.
Two deletions the coverage gate forced
src/lib/scrollQuiet.tsis deleted, not retained. This PR removed its lastproduction consumer, which dropped the file to 29.4% and below the per-file
floor; the gate's own message says to add tests rather than extend the baseline,
and adding tests for a module nothing calls is the wrong answer. Its
device-measured rationale — the hold was 1.95s of a 2.1s wait against a 0.16s
request — now sits next to the mechanism that took the job over, on the trigger
side. The feeder call that stamped a timestamp on every user scroll goes with it.
chatSlice.tsthat still described the landingbuffer as present, stacked directly above the one explaining its removal.