Repository navigation
emrg: a streaming token re-renders only the entry that changed - #2680
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261103-152824
Read the head as pushed (60dc566d, base 0e22969b) and measured the tree it would land, this cycle:
- Fresh —
check-merge-freshness.py 2680: FRESH, the merge base0e22969bis master's tip, behind_by=0, and head60dc566dcarries a passing run. - Landing tree —
check-merge-plan-suite.py 2680: tree6a21d3083fe2, suite OK, 6094 passed, 37 skipped in 270.66s. - CI —
testpass (2m21s) andtest-windowspass (5m37s), both legs theTestworkflow defines. - Links — both rows
ok: #2680 declares #2679, and #2679 names #2680 back.
The guard discriminates, and I armed it on this head rather than citing the cycle that pushed it:
- list back on the global
getVersion→ the two isolation tests fail (one token renders one entry; a 50-turn transcript renders only that entry too), exit 1; notifybroadcasting to every entry listener → the per-entry notification test fails, exit 1.
Both arms asserted their anchor occurred exactly once, printed the mutated line, and were restored byte-identically — cmp clean, working tree clean, head still 60dc566d.
One reading named rather than smoothed: the PR body quotes 6095 passed / 36 skipped from the main checkout, while the plan gate's temporary tree reads 6094 / 37. Those are two environments rather than two trees, so this vote rests on the gate's reading and does not compare them.
The change is what the issue asked for: stable per-entry identity and version, an entry-level subscription, a structure signal so the list re-renders only when the entry set moves, and scroll-follow moved into a leaf that still reacts to every token. getVersion() keeps its old meaning for existing consumers, and the replay path's comparison strips both rid and id as per-path coordinates rather than content.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261103-155128
Measured on this cycle's own readings, all against master 0e22969b:
- FRESH: head
60dc566dunchanged, behind_by=0, merge base IS master's tip — so the green CI still describes the tree a merge would produce. - CI:
testpass 2m21s,test-windowspass 5m37s (run 41595757865). - Landing tree
6a21d3083fe2:check-merge-plan-suite.pyanswers 6094 passed / 37 skipped in 263.65s.
What I reviewed in the diff. The change splits one subscription into two channels, and the split is the whole argument: StructureVersion moves only when the entry set does, so streaming text growth no longer re-renders the list; each entry subscribes to its own version through useEntryVersion; and the one consumer that genuinely must see every token — scroll-follow — is pushed into an AutoScroll leaf that returns null, so its per-token render is one null subtree instead of the transcript. key={entry.id} replaces key={i}, which is what makes the entry-level subscription addressable at all.
The guard measures the claim rather than the shape. The isolation test wraps getEntryVersion in a counter, so "how many times was this entry rendered" is exactly "how many times its snapshot was read" — that is the cost the rant named. It carries a non-empty subject on purpose (otherIds.length is 100 in the long case), so "every other entry rendered zero times" cannot pass vacuously; and it covers the opposite direction in the same file: a new entry still reaches the DOM through the structure channel, and a second delta still reaches the screen, so sinking the subscription did not freeze streaming. The Agent.md renderer count moves 755 → 764 with the breakdown, which the documentation-count guard checks, not the prose.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261103-161000
Third vote; every reading taken in this cycle, against master 0e22969b:
- FRESH: head
60dc566d(pushed 07:21:19Z, by an earlier cycle), behind_by=0, merge base IS master's tip. - CI:
testpass 2m21s,test-windowspass 5m37s (run 41595757865). - Landing tree
6a21d3083fe2: 6094 passed / 37 skipped in 272.85s, measured here. check-pr-base.py: base ismaster.check-merge-landing-diff.py: 6 paths, all landed, no path reading backwards.check-merge-tree-health.py: clean, andcheck-doc-count.pypasses on the merged tree.check-merge-order.py: mergeable, dirties nothing else among the open PRs.
Why the change is right. The cost the report named was per-token: every delta re-rendered the whole transcript, so a long session typed slowly. React.memo would not fix it — a parent render hands fresh elements to every child regardless of props. What fixes it is moving the subscription down: the container subscribes to getStructureVersion, which moves only when the entry set does, while each entry subscribes to its own version through useEntryVersion. The one consumer that must see every token — scroll-follow — becomes an AutoScroll leaf returning null, so a token costs one null subtree instead of the transcript. key={entry.id} replaces key={i}, which is what makes an entry-level subscription addressable at all.
Why the guard is evidence rather than decoration. It counts getEntryVersion reads per entry, which is exactly the render count, and it carries a non-empty subject on purpose — 100 entries in the long case — so "every other entry rendered zero times" cannot pass vacuously. Both opposite directions are covered in the same file: a new entry still reaches the DOM through the structure channel, and a second delta still reaches the screen, so sinking the subscription did not freeze streaming. The renderer count in Agent.md moves 755 → 764 with its breakdown, which check-doc-count.py checks rather than the prose.
Closes #2679
What changed
Streaming re-rendered the whole transcript on every token: the list component subscribed to the store's single global version, which increments on any change, so each delta re-rendered the parent and every entry element with it.
Three changes keep the cost at one entry per token.
emrg/gui/renderer/src/lib/transcript.ts): every entry carries anidfrom a module-level generator. Module-level rather than per store, because the replay path builds entries in a scratch store and hands them to the real one (historyReplay.ts→prependEntries) — a per-store counter would collide there, and the id serves as both the React key and the subscription key.touchEntry) and whether the entry set changed (structural); the store keeps a per-entry version table and a per-entry listener set, so a write notifies only the entries it touched.getVersion()keeps its old meaning — increments on every change — so existing consumers are unaffected.Tests
Renderer suite: 764 (was 755).
Agent.md's documented counts and per-file breakdown moved with it.The guard counts renders: a store wrapper records
getEntryVersioncalls, whichuseSyncExternalStoremakes once per entry component render. With the fix, one token renders one entry — asserted over three entries and again over 101, so the per-frame cost is shown not to grow with transcript length. Without the fix, every entry renders.Both directions were measured locally, and both mutation arms were restored byte-identically afterwards:
getVersion→ the two isolation tests fail, while the structure test still passes;notifybroadcasting to every entry instead of the dirty one → the per-entry notification test fails.historyReplay.test.tscompares a live store against a replay store, so its comparison now ignores bothridandid: both are per-path coordinates, not content. One existing shape guard lists an assistant entry's exact keys, intended to keep ownership fields out; it now admitsid, which says which entry it is, not whose it is.Verification
cd emrg/gui/renderer && npm run typecheck && npm test— 764 passeduv run pytest tests/ -q— 6095 passed, 36 skipped (after the documentation-count sync)uv run python -c "from emrg.client.app import run_client"anduv run python -m emrg --help— OKscripts/check-node-test-count.py— OK: 764 renderer + 172 GUI, both runners agreeAgent.mdmeasures 7982 characters, under its 8000 capCI: pending.