feat(tui): rebuild session timeline on Quark part slots#37603
Open
kitlangton wants to merge 15 commits into
Open
feat(tui): rebuild session timeline on Quark part slots#37603kitlangton wants to merge 15 commits into
kitlangton wants to merge 15 commits into
Conversation
Port of #36433 onto the quark timeline branch: pending operations journal, pending-derived inputs, and hydration that reappends admitted-but-unprojected prompts. Composed with part-collection reseeding.
Sync the vendored packages/quark reactivity with the standalone repo's internal kernel (quark 2a68785) and drop the alien-signals dependency. Kernel algorithm adapted from alien-signals 3.2.1 with MIT attribution in packages/quark/THIRD_PARTY_NOTICES.md.
Vendored sync of quark 15159e5: State and Computed wrappers share this-based set/update/subscribe methods through a node handle, cutting three closures per slot. Verified no opencode consumer detaches quark wrapper methods.
Wrapper methods are shared this-based functions after the standalone sync, so the test's subscribe interception must bind before detaching.
- fix permission input losing reactivity (raw slot read; regression test) - BackgroundToolHint tracks structure + tool slots instead of whole-collection values - group views share mapArray-backed usePartSlots (per-ref node reuse) - dedupe streaming handlers behind variant-scoped modify/insert helpers - part ID scheme has one definition; hasText required in message navigation - expose read-only PartsView to view components; misc cleanups
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
When the TUI streams an assistant response, every delta today rewrites the
message's
contentarray inside a Solid store, and everything downstream —the timeline reducer, every part view, every group label — rediscovers its
place in that array by scanning it. The work per delta grows with the size of
the message being streamed.
This PR insources Quark (
packages/quark), a small reactivekeyed-collection library over alien-signals, and rebuilds session message
content on it. Each part of an assistant message gets a stable reactive
slot addressed by an identity the wire already provides (
text:{ordinal},reasoning:{ordinal}, tool callID). A streaming delta becomes one keyedwrite that publishes exactly one slot.
The claim is measured at the publication level: a new isolation test streams
two deltas into a message that also holds a tool part — the text slot
publishes twice, the sibling tool slot publishes zero times, the part
structure publishes zero times. And the migration deleted complexity
rather than adding it: the positional part-resolution bridge, three
findLastscanning helpers, and a dead component are gone (~180 lines).Before: every delta rediscovers everything
The pre-Quark v2 baseline (
5d5b33f195) pays an O(message) scan threetimes per delta, at three different layers.
Layer 1 — the store handler scans for its target. Parts have no
address, so the handler finds "the currently-streaming text part" by
scanning the content array backwards, then mutates a store draft
(
packages/tui/src/context/data.tsxat the baseline):Layer 2 — the timeline reducer recomputes every part's identity. The
row reducer derives from the store, so it re-runs on every delta — and it
reconstructs each part's ID by re-counting ordinals across the whole
message, every time (
rows.tsat the baseline):Layer 3 — every view resolves its part positionally. A row knows its
partID, but the data lives in the array, so rendering scans again — afindfor tools, a regex-plus-filter for text and reasoning:The deeper cost is invalidation granularity. The Solid store path for a
message changes on every delta, so every memo derived from that message —
sibling tool rows, reasoning group labels, the background-tool hint —
re-evaluates, re-scans, and (usually) concludes nothing it cares about
changed. The array is the unit of change; nothing smaller exists to
subscribe to.
The design: identity comes from the wire, once
The fix is not a faster scan — it is noticing that nothing needs to be
found. Streaming events already name their target part:
session.text.*ordinal: 0text:0session.reasoning.*ordinal: 1reasoning:1session.tool.*callID: "call_x9k2"call_x9k2SessionContent(packages/tui/src/routes/session/content.ts) gives eachassistant message one keyed collection of parts under that scheme. Identity
is assigned exactly once — on the wire event, or by
withPartIDswhenseeding from a fetched array — and every layer after that is a keyed lookup.
This is Quark's design thesis in one sentence: identity, equivalence, and
dependencies are declared or observed inputs, never rediscovered per
update.
How Quark works
Quark is four small modules (
packages/quark/src/, ~1,200 lines, 24 tests).Bottom up:
Readable: a value you can call and subscribe to
reactivity.tswraps alien-signals in one interface:Calling it inside any reactive context (a Solid memo, another signal) tracks
it;
subscribeis for imperative consumers like tests.Transaction.runbatches multiple publications into one flush. That's the whole reactive
substrate — Quark adds no scheduler of its own.
Keyed: a collection where each key owns a stable slot
keyed.tsis the core. A collection is two things:byKey: Map<Key, Writable<A>>— one slot (a writable signal) per key.The slot object is created when the key first appears and never replaced
while the key lives. Subscribers attach to the slot, not to the collection.
slots: Readable<readonly Readable<A>[]>— the structure: the orderedlist of slots. It publishes only on insert, remove, or reorder.
Every value mutation funnels through one gate:
Three properties fall out of this shape:
modify(key, f)is a maplookup plus one
publish. No other slot, and not the structure, istouched. This is the O(1)-per-delta guarantee — it is architectural, not
an optimization.
set(values)(used whenreseeding from a fetch) walks the new array and reuses the existing
slot for every surviving key, publishing only slots whose values actually
changed under the declared equivalence. A refetch that confirms what
streaming already wrote publishes nothing.
update/modify/move/removereturn
falsewhen the key is missing; only programmer errors throw(duplicate keys, key mutation). In the TUI, straggler deltas racing a
session deletion or revert are normal — they no-op, with zero guard
code at call sites.
The
metricscounters (slotPublications,structuralPublications,equivalenceSuppressions) exist so tests can assert these properties asnumbers instead of vibes.
Layout: equivalence is declared, then compiled
publishneeds anequivalentfunction, and writing deep comparisons byhand for every shape is where such designs usually rot.
layout.tsinsteadtakes a declared shape — which fields are reactive, which are immutable,
what equivalence each field uses — and compiles it. The TUI's entire
description of assistant content is this one declaration:
Reading it: parts are a tagged union on
type, keyed bypartID. For atext part, only
textmatters, compared as a string. Tool sub-objects(
state,time, …) are streamed immutably — the handlers always replacethem wholesale — so reference equality (
reference = { equivalent: Object.is })is both correct and the cheapest possible comparison.
immutablefieldsare excluded from comparison entirely.
Layout.compileproduces aPlan: a key extractor, a whole-valuecomparator, and
Plan.diff, a per-field change bitmask — one comparisontells the collection whether and what changed. The comparator is
generated as code from the declaration (with a closure-based fallback for
CSP environments); measured in the standalone repo's benches, the generated
comparator runs at 0.74–0.82x the cost of a handwritten one.
useSlot: the granularity reaches the view
A reactive collection is useless if the view layer collapses it back to
coarse updates.
solid.tsbridges with two functions.useValueadapts aReadableto a Solid accessor viafrom.useSlotis the important one:It tracks in two phases. While the slot doesn't exist yet, the component
depends on the collection's structure — so it wakes when the part is
born. The moment the slot exists, the memo chain narrows: value changes flow
through the slot alone, and structural churn elsewhere in the collection
(new tool parts, reordering) can no longer re-run this component.
After: one delta, one publication
The three layers from "Before" collapse. The handler is a keyed modify — no
scan, no draft, no message-level invalidation:
The timeline reducer emits rows of
PartRefs (message ID + part ID) andnever recomputes ordinals — parts arrive with their IDs. Row identity
survives content churn, so the timeline's structure only changes on
structural events.
A part view is one slot read (
index.tsx):sequenceDiagram participant W as Wire event participant C as SessionContent participant S as Slot text:0 participant V as TextPart view participant X as Siblings (tool rows, groups, timeline) W->>C: session.text.delta (ordinal 0, "chunk") C->>S: modify("text:0", append) — Map lookup + publish S->>V: 1 slot publication Note over X: no publication, no re-evaluationThe isolation test (
data.test.tsx, "publishes streaming deltas to one partslot without touching siblings") pins this down with the metrics counters:
two deltas into a message holding a tool part yield exactly
{ text: 2, tool: 0, structure: 0 }publications, independent of messagesize.
Why the code is cleaner, not just faster
text:{ordinal}scheme is assignedat the wire boundary (
SessionContent.withPartIDsfor fetched arrays) andeverything downstream is a lookup.
resolvePart's regex-and-recountbridge, and the reducer's ordinal loop, had no remaining job — deleted.
latestText,latestReasoning,latestTool— deleted. Cold consumers (copy lastresponse, transcript export, message navigation, the permission dialog's
callID lookup) became
parts(...).get(id)orparts(...).values().for deleted sessions no-op through
modify'sfalsereturn. The old codeneeded
if (match)at every handler; the new code needs nothing.index.tsx−295/+relocated, deadAssistantMessagecomponentremoved, ~180 lines of resolution machinery gone.
A bug the migration introduced — caught, reproduced, fixed
An overnight stress probe (fresh TUI, one prompt, verify response; 40
iterations) hit one projection miss: the durable log showed a complete,
settled execution, but the TUI never rendered the text. Investigation found
a real defect in the new seam, and the fix is itself evidence for the
design's central invariant.
message.syncrefetched the message list, then didcontent.drop(sessionID)followed by reseeding — replacing the collection objects. But a mounted
useSlotcaptures its collection reference once, for its lifetime. After async, live deltas wrote to the new collection while mounted rows stayed
subscribed to the dead one: durable state correct, frame frozen, no later
event to repair it.
The regression test ("keeps part slot references alive across message
sync") reproduces it deterministically: hold the collection reference like
a mounted view, force a sync with an identical snapshot, stream one more
delta, and assert the old reference sees it. The fix
(
fix(tui): preserve part collection identity across sync) replacesdrop-and-recreate with reseed-in-place plus
prunefor messages thatactually vanished —
set()already preserves slot identity for survivingkeys, so mounted subscriptions ride through refetches untouched.
The invariant, stated once and now tested: a (session, message)
collection's identity is part of its contract — it is never recreated while
alive.
How (file map)
packages/quark/— vendored library:reactivity.ts,keyed.ts,layout.ts,solid.ts, tests, benches. Byte-identical to the standaloneauthoritative repo.
packages/tui/src/routes/session/content.ts—SessionContent: thepart layout, ID scheme, per-message collection registry
(
get/ensure/seed/drop/prune).packages/tui/src/context/data.tsx— all 11 streaming handlersrewritten to slot operations; sync/model-switch seeding; revert/delete
cleanup; exposes
data.session.message.parts(sessionID, messageID).packages/tui/src/routes/session/timeline.ts,rows.ts— reduceremits
PartRefrows; acceptspartsOffrom the collections.packages/tui/src/routes/session/index.tsx,permission.tsx,dialog-message.tsx,message-navigation.ts— consumers migrated toslot reads.
negative results) live in the Quark repo under
docs/experiments/to keepthis diff to code.
Scope
carried the identities this PR consumes. Base is the v2 line
(merge-base
5d5b33f195).authoritative;
packages/quarkis a synced copy. Publishing is afollow-up decision.
comparisons are recorded in the Quark repo's
docs/experiments/quark-tui-timeline.mdbut aredominated by unrelated costs and do not establish end-to-end speedup;
the doc says so explicitly.
recent live events can still briefly clobber newer streamed state (the
same window existed in the store path on the baseline). The identity fix
removes the permanent failure; the transient window is follow-up work.
Testing
packages/tui:bun typecheckclean;bun run test test/cli/tui/—114 pass, 0 fail, including the slot-isolation test, the sync-identity
regression test, and a permission-input reactivity regression test.
packages/quark:bun --conditions=browser test— 63 pass;bunx tsc --noEmitclean.script/quark-timeline-drive.tsvia opencode-drive against alive instance built from this branch — streams 40 chunks of reasoning +
text, verifies the rendered screen and the projected API content
(
tui_timeline_drive_ms=5297).failure was diagnosed, reproduced as a deterministic regression test, and
fixed (see above). After porting fix(tui): preserve prompts during session hydration #36433 (prompt hydration preservation)
onto the branch, the initial-hydration stress probe passed 20/20.
service restores the session and transcript, and post-restart prompting
works. (The prior "Session not found" reproduction was a harness artifact:
the drive runner used an in-memory database, so the restart erased it; the
TUI's toast-and-navigate-home was correct graceful degradation.)
perf/keyed.md): generatedcomparators at 0.74–0.82x of handwritten; observed-reads gating cut the
indexed value-only update tax from 2.6x to ~1.5–1.7x.