Skip to content

emrg: a streaming token re-renders only the entry that changed - #2680

Merged
argszero merged 1 commit into
masterfrom
feature/gui-per-entry-stream-render
Nov 3, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/gui-per-entry-stream-render

Conversation

@argszero

@argszero argszero commented Nov 3, 2026

Copy link
Copy Markdown
Owner

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.

  • Entry identity (emrg/gui/renderer/src/lib/transcript.ts): every entry carries an id from 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.
  • Per-entry version and subscription: mutations declare what they wrote (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.
  • The list reads a structure version: it re-renders only when entries are added, prepended or cleared, when the top history bar changes, or on a session switch. Scroll-follow moved into a leaf component that subscribes to the global version and returns null, so it still reacts to every token without dragging the entry list through a render.

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 getEntryVersion calls, which useSyncExternalStore makes 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:

  • list back on getVersion → the two isolation tests fail, while the structure test still passes;
  • notify broadcasting to every entry instead of the dirty one → the per-entry notification test fails.

historyReplay.test.ts compares a live store against a replay store, so its comparison now ignores both rid and id: 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 admits id, which says which entry it is, not whose it is.

Verification

  • cd emrg/gui/renderer && npm run typecheck && npm test — 764 passed
  • uv run pytest tests/ -q — 6095 passed, 36 skipped (after the documentation-count sync)
  • uv run python -c "from emrg.client.app import run_client" and uv run python -m emrg --help — OK
  • scripts/check-node-test-count.py — OK: 764 renderer + 172 GUI, both runners agree
  • Agent.md measures 7982 characters, under its 8000 cap

CI: pending.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ 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 base 0e22969b is master's tip, behind_by=0, and head 60dc566d carries a passing run.
  • Landing tree — check-merge-plan-suite.py 2680: tree 6a21d3083fe2, suite OK, 6094 passed, 37 skipped in 270.66s.
  • CI — test pass (2m21s) and test-windows pass (5m37s), both legs the Test workflow 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;
  • notify broadcasting 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ LGTM — cycle cyc20261103-155128

Measured on this cycle's own readings, all against master 0e22969b:

  • FRESH: head 60dc566d unchanged, behind_by=0, merge base IS master's tip — so the green CI still describes the tree a merge would produce.
  • CI: test pass 2m21s, test-windows pass 5m37s (run 41595757865).
  • Landing tree 6a21d3083fe2: check-merge-plan-suite.py answers 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 argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

✅ 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: test pass 2m21s, test-windows pass 5m37s (run 41595757865).
  • Landing tree 6a21d3083fe2: 6094 passed / 37 skipped in 272.85s, measured here.
  • check-pr-base.py: base is master. check-merge-landing-diff.py: 6 paths, all landed, no path reading backwards. check-merge-tree-health.py: clean, and check-doc-count.py passes 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.

@argszero
argszero merged commit b670895 into master Nov 3, 2026
2 checks passed
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.

GUI: a streamed token re-renders the whole transcript

1 participant