Skip to content

feat(tui): rebuild session timeline on Quark part slots#37603

Open
kitlangton wants to merge 15 commits into
v2from
quark-timeline
Open

feat(tui): rebuild session timeline on Quark part slots#37603
kitlangton wants to merge 15 commits into
v2from
quark-timeline

Conversation

@kitlangton

@kitlangton kitlangton commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What

When the TUI streams an assistant response, every delta today rewrites the
message's content array 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 reactive
keyed-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 keyed
write 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
findLast scanning 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 three
times 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.tsx at the baseline):

case "session.text.delta":
  message.update(event.data.sessionID, (draft, index) => {
    const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
    if (match) match.text += event.data.delta
  })
  break
latestText(assistant: SessionMessageAssistant | undefined) {
  return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
}

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.ts at the baseline):

message.content.forEach((part) => {
  const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
  if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
  append(rows, { messageID: message.id, partID }, part)
})

Layer 3 — every view resolves its part positionally. A row knows its
partID, but the data lives in the array, so rendering scans again — a
find for tools, a regex-plus-filter for text and reasoning:

function resolvePart(message: SessionMessageAssistant, partID: string) {
  const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
  if (tool) return tool
  const match = /^(text|reasoning):(\d+)$/.exec(partID)
  if (!match) return
  const ordinal = Number(match[2])
  return message.content.filter((part) => part.type === match[1])[ordinal]
}

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:

Wire event Carries Part ID
session.text.* ordinal: 0 text:0
session.reasoning.* ordinal: 1 reasoning:1
session.tool.* callID: "call_x9k2" call_x9k2

SessionContent (packages/tui/src/routes/session/content.ts) gives each
assistant message one keyed collection of parts under that scheme. Identity
is assigned exactly once — on the wire event, or by withPartIDs when
seeding 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.ts wraps alien-signals in one interface:

export interface Readable<A> {
  (): A
  subscribe(listener: (value: A) => void): () => void
}

Calling it inside any reactive context (a Solid memo, another signal) tracks
it; subscribe is for imperative consumers like tests. Transaction.run
batches 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.ts is 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 ordered
    list of slots. It publishes only on insert, remove, or reorder.

Every value mutation funnels through one gate:

function publish(slot: Writable<A>, value: A) {
  const current = slot()
  if (current === value || equivalent(current, value)) {
    if (options.metrics) options.metrics.equivalenceSuppressions++
    return false
  }
  slot.set(value)
  if (options.metrics) options.metrics.slotPublications++
  return true
}

Three properties fall out of this shape:

  1. A value change is one slot publication. modify(key, f) is a map
    lookup plus one publish. No other slot, and not the structure, is
    touched. This is the O(1)-per-delta guarantee — it is architectural, not
    an optimization.
  2. Reconciliation preserves slot identity. set(values) (used when
    reseeding 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.
  3. Absent keys are data, not errors. update/modify/move/remove
    return false when 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 metrics counters (slotPublications, structuralPublications,
equivalenceSuppressions) exist so tests can assert these properties as
numbers instead of vibes.

Layout: equivalence is declared, then compiled

publish needs an equivalent function, and writing deep comparisons by
hand for every shape is where such designs usually rot. layout.ts instead
takes 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:

const PartLayout = Layout.keyedUnion({
  key: Layout.key("partID", Layout.string),
  tag: "type",
  variants: {
    text: Layout.struct({ text: Layout.string }),
    reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }),
    tool: Layout.struct({
      id: Layout.immutable(Layout.string),
      name: Layout.immutable(Layout.string),
      executed: reference,
      providerState: reference,
      providerResultState: reference,
      state: reference,
      time: reference,
    }),
  },
})
const PartPlan = Layout.compile(PartLayout)

Reading it: parts are a tagged union on type, keyed by partID. For a
text part, only text matters, compared as a string. Tool sub-objects
(state, time, …) are streamed immutably — the handlers always replace
them wholesale — so reference equality (reference = { equivalent: Object.is })
is both correct and the cheapest possible comparison. immutable fields
are excluded from comparison entirely.

Layout.compile produces a Plan: a key extractor, a whole-value
comparator, and Plan.diff, a per-field change bitmask — one comparison
tells 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.ts bridges with two functions. useValue adapts a
Readable to a Solid accessor via from. useSlot is the important one:

export function useSlot<A, Key>(keyed: Keyed.Keyed<A, Key>, key: () => Key): Accessor<A | undefined> {
  const structure = useValue(keyed.slots)
  const slot = createMemo(() => {
    structure()
    return keyed.get(key())
  })
  const value = createMemo(() => {
    const current = slot()
    return current ? useValue(current) : undefined
  })
  return () => value()?.()
}

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:

case "session.text.delta":
  content
    .get(event.data.sessionID, event.data.assistantMessageID)
    ?.modify(SessionContent.textID(event.data.ordinal), (part) =>
      part.type === "text" ? { ...part, text: part.text + event.data.delta } : part,
    )
  break

The timeline reducer emits rows of PartRefs (message ID + part ID) and
never 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):

function SessionPartView(props: { partRef: PartRef; ... }) {
  // One reactive slot per part: unrelated deltas in the same message cannot
  // re-render this row.
  const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID)
  return <Show when={part()}>{(item) => <Switch></Switch>}</Show>
}
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-evaluation
Loading

The isolation test (data.test.tsx, "publishes streaming deltas to one part
slot 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 message
size.

Why the code is cleaner, not just faster

  • Identity lives in one place. The text:{ordinal} scheme is assigned
    at the wire boundary (SessionContent.withPartIDs for fetched arrays) and
    everything downstream is a lookup. resolvePart's regex-and-recount
    bridge, and the reducer's ordinal loop, had no remaining job — deleted.
  • Scanning helpers had no remaining job. latestText,
    latestReasoning, latestTool — deleted. Cold consumers (copy last
    response, transcript export, message navigation, the permission dialog's
    callID lookup) became parts(...).get(id) or parts(...).values().
  • Race handling became a convention instead of guards. Straggler events
    for deleted sessions no-op through modify's false return. The old code
    needed if (match) at every handler; the new code needs nothing.
  • Net: index.tsx −295/+relocated, dead AssistantMessage component
    removed, ~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.sync refetched the message list, then did content.drop(sessionID)
followed by reseeding — replacing the collection objects. But a mounted
useSlot captures its collection reference once, for its lifetime. After a
sync, 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) replaces
drop-and-recreate with reseed-in-place plus prune for messages that
actually vanished — set() already preserves slot identity for surviving
keys, 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 standalone
    authoritative repo.
  • packages/tui/src/routes/session/content.tsSessionContent: the
    part layout, ID scheme, per-message collection registry
    (get/ensure/seed/drop/prune).
  • packages/tui/src/context/data.tsx — all 11 streaming handlers
    rewritten 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 — reducer
    emits PartRef rows; accepts partsOf from the collections.
  • packages/tui/src/routes/session/index.tsx, permission.tsx,
    dialog-message.tsx, message-navigation.ts
    — consumers migrated to
    slot reads.
  • Experiment protocol, measurements, and decision records (including
    negative results) live in the Quark repo under docs/experiments/ to keep
    this diff to code.

Scope

  • TUI-only. No server, protocol, or schema changes — the wire already
    carried the identities this PR consumes. Base is the v2 line
    (merge-base 5d5b33f195).
  • Vendored, not published. The standalone Quark repo stays
    authoritative; packages/quark is a synced copy. Publishing is a
    follow-up decision.
  • Publication-level guarantee, not frame timing. Drive wall-clock
    comparisons are recorded in the Quark repo's
    docs/experiments/quark-tui-timeline.md but are
    dominated by unrelated costs and do not establish end-to-end speedup;
    the doc says so explicitly.
  • Pre-existing refetch window not addressed. A snapshot fetched before
    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 typecheck clean; 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 --noEmit clean.
  • End-to-end: script/quark-timeline-drive.ts via opencode-drive against a
    live 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).
  • Stress: 40 fresh-TUI prompt/response iterations; the single observed
    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.
  • Restart recovery: with a file-backed database, a kill-and-relaunch of the
    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.)
  • Library benchmarks (standalone repo, perf/keyed.md): generated
    comparators 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant