Browse Source

feat(tui): add quark timeline experiment

Kit Langton 1 month ago
parent
commit
daf8e539bf

+ 13 - 0
bun.lock

@@ -627,6 +627,14 @@
         "typescript": "catalog:",
       },
     },
+    "packages/quark": {
+      "name": "effect-quark",
+      "version": "0.0.0",
+      "dependencies": {
+        "alien-signals": "3.2.1",
+        "solid-js": "catalog:",
+      },
+    },
     "packages/schema": {
       "name": "@opencode-ai/schema",
       "version": "1.17.11",
@@ -893,6 +901,7 @@
         "clipboardy": "4.0.0",
         "diff": "catalog:",
         "effect": "catalog:",
+        "effect-quark": "workspace:*",
         "fuzzysort": "catalog:",
         "get-east-asian-width": "catalog:",
         "open": "10.1.2",
@@ -3315,6 +3324,8 @@
 
     "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
 
+    "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
+
     "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
 
     "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
@@ -3859,6 +3870,8 @@
 
     "effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
 
+    "effect-quark": ["effect-quark@workspace:packages/quark"],
+
     "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
 
     "electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="],

+ 303 - 0
docs/design/quark-architecture-review.md

@@ -0,0 +1,303 @@
+# Quark Timeline Architecture — and How It Can Be Faster
+
+An architecture review of the `opencode-quark-timeline` experiment: what the layers are, why the design is sound, and the *mechanical* reason it can beat the Solid Store timeline — with the actual code.
+
+## The Stack in One Picture
+
+Four small layers, each with one job. The reusable core remains about 200 lines.
+
+```mermaid
+flowchart TD
+    E[Server events] --> D[DataProvider<br/>Solid Store, unchanged]
+    D --> R[createSessionRows<br/>row reduction policy]
+    R -->|"set / update / insert / remove"| K[Keyed&lt;SessionRow, string&gt;<br/>packages/quark/src/keyed.ts]
+    K -->|"slots — structure channel"| FOR["Solid &lt;For&gt;"]
+    K -->|"slot — per-row value channel"| UV[useValue adapter<br/>one Solid signal per row]
+    K -->|"slots + immutable identity"| BM[boundaries memo]
+    UV --> SRV[SessionRowView]
+    FOR --> SRV
+```
+
+```tree
+packages/quark/src
+├── reactivity.ts   # 59 lines — Readable/Writable/Computed/Transaction over alien-signals
+├── keyed.ts        # Keyed collection (the whole idea lives here)
+├── solid.ts        # 8 lines — useValue: one quark Readable → one Solid signal
+└── index.ts        # 2 lines — exports
+```
+
+The experimental boundary is disciplined: `DataProvider`, the event protocol,
+rendered row policy, and `SessionRowView` are unchanged. Row ownership and the
+incremental mutation mechanics changed; batch and live grouping now share one
+policy helper. The boundary remains narrow enough to attribute regressions or
+improvements.
+
+## The Core Idea: Identity and Equivalence Are Inputs, Not Discoveries
+
+This is the entire architectural bet. Solid Store must *discover* what changed; Quark is *told* what identity and sameness mean, once, at construction:
+
+```typescript title="packages/tui/src/routes/session/rows.ts" caption="The complete reconciliation policy is two functions"
+const state = Keyed.make({ key: rowKey, equivalent: sameRow })
+```
+
+`rowKey` answers *"which slot is this?"* — including the subtle case where a group's key is the ref that **created** it, so refs moving to `pending` can never change identity. Keys are precomputed once at row construction with length-prefixed segments, so reconciliation pays zero key-extraction work:
+
+```typescript title="packages/tui/src/routes/session/rows.ts" caption="Keys are built once, at construction — rowKey is a field read"
+export type SessionRow = { readonly id: string } & ( /* ...variants... */ )
+
+function partRow(ref: PartRef): SessionRow {
+  return { id: `p${segment(ref.messageID)}${segment(ref.partID)}`, type: "part", ref }
+}
+
+function segment(value: string) {
+  return `${value.length}:${value}` // unambiguous without trusting a delimiter
+}
+
+function rowKey(row: SessionRow) {
+  return row.id
+}
+```
+
+`sameRow` answers a different question: *"can any consumer tell these two values apart?"* It compares only render-relevant fields. If it says yes-they're-the-same, **nothing downstream runs at all**.
+
+## Two Channels Instead of One
+
+A Solid Store exposes one reactive graph; every consumer subscribes into the same proxy web. `Keyed` splits change into two independent surfaces:
+
+```typescript title="packages/quark/src/keyed.ts" start=4
+export interface Keyed<A, Key> {
+  readonly slots: Readable<readonly Readable<A>[]>   // fires ONLY on insert/remove/reorder
+  readonly values: Readable<readonly A[]>            // fires on any current value change
+  has(key: Key): boolean
+  get(key: Key): Readable<A> | undefined
+  set(values: readonly A[]): void
+  update(value: A): boolean
+  insert(value: A, position?: "end" | { before: Key } | { after: Key }): Readable<A>
+  remove(key: Key): boolean
+  move(key: Key, position?: "end" | { before: Key } | { after: Key }): boolean
+}
+```
+
+```definitions
+[
+  { "term": "slots", "definition": "The structure channel. An array of stable per-row readables. Its identity changes only when membership or order changes. <For> subscribes here." },
+  { "term": "slot", "definition": "The value channel. One writable signal per row identity. A streaming delta touches exactly one slot. The row's component subscribes here via useValue." },
+  { "term": "values", "definition": "The aggregate channel. A lazy computed mapping slots to current values. The TUI uses it only for internal mutation snapshots and does not subscribe boundaries to it." }
+]
+```
+
+This separation is what a proxy-based store cannot give you for free: **a value change and a structure change are different events**, published to different audiences.
+
+## How It CAN Be Faster: One Unique Row Update, Step by Step
+
+The highest-frequency reducer workload is a repeated streaming ordinal, which
+the seen-part set rejects in expected `O(1)` before reading the aggregate. The
+next useful comparison is a unique event that extends or completes an existing
+group row. Trace that value update through both systems.
+
+### Before: Solid Store path
+
+```typescript caption="Old hot path — every access and write crosses a proxy"
+setRows(produce((draft) => {
+  // 1. draft is a proxy — every property read is a trap
+  // 2. finding the group row walks proxied array elements
+  // 3. the mutation writes through proxy machinery
+  // 4. Solid records fine-grained dependencies per touched path
+  append(draft, ref, part, queuedStart(draft))
+}))
+// ...and on reconnect / revert / rebuild:
+setRows(reconcile(reduce()))
+// reconcile must re-derive identity from item references,
+// walk every row's shape, and diff against the proxy graph
+```
+
+Solid's generality — arbitrary nested access, partial path writes, plain objects — is paid for on **every operation** with proxy traps, shape inspection, and dependency bookkeeping.
+
+### After: Quark path
+
+The whole collection is a closure over three pieces of state — the structure signal, the identity map, and the declared equivalence. `update` reads directly against them:
+
+```typescript title="packages/quark/src/keyed.ts" caption="The closure state update() operates on, plus the whole hot path"
+export function make<A, Key>(options: {
+  readonly key: (value: A) => Key
+  readonly equivalent?: (left: A, right: A) => boolean
+}): Keyed<A, Key> {
+  const slots = State.make<readonly Writable<A>[]>([]) // structure channel: one signal holding the slot array
+  const byKey = new Map<Key, Writable<A>>()            // identity → slot address, maintained by set/insert/remove
+  const equivalent = options.equivalent ?? Object.is   // declared sameness (sameRow in the TUI)
+  const values = Computed.make<readonly A[]>((previous) => {
+    const next = slots().map((slot) => slot())         // aggregate channel, lazy until subscribed
+    return same(previous, next) ? previous! : next
+  })
+
+  return {
+    slots,
+    values,
+    // ...
+    update(value) {
+      const key = options.key(value)          // 1. one key extraction (a field read: row.id)
+      const slot = byKey.get(key)             // 2. one Map lookup — an address, not a search
+      if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`)
+      if (equivalent(slot(), value)) return false // 3. one equivalence check — early cutoff
+      slot.set(value)                         // 4. one signal write; slots is untouched
+      return true
+    },
+    // ...
+  }
+}
+```
+
+Note what `update` never touches: `slots`. A value change writes one slot signal and the structure channel stays reference-identical — that single fact is the structural cutoff below.
+
+Four operations. No proxies, no shape discovery, no graph diff. Then the adapter forwards the change to exactly one Solid signal:
+
+```typescript title="packages/quark/src/solid.ts" caption="The entire Solid boundary — 8 lines"
+export function useValue<A>(readable: Readable<A>): Accessor<A> {
+  const [value, setValue] = createSignal(readable())
+  onCleanup(readable.subscribe((next) => setValue(() => next)))
+  return value
+}
+```
+
+### The structural cutoff — why `<For>` never even wakes up
+
+Because `slots` only fires on structural change, a value-only update leaves the outer array **reference-identical**. The `<For>` signal never fires, so keyed-list diffing never runs and the component owner survives:
+
+```typescript caption="Value-only transition: structure untouched, owner preserved"
+const structure = rows.slots()
+const groupSlot = structure[0]
+
+rows.update({ ...initial, completed: true })
+
+rows.slots() === structure       // true — <For> receives no change at all
+rows.slots()[0] === groupSlot    // true — component owner survives
+groupSlot().completed === true   // true — the one subscribed row re-renders
+```
+
+This is also the **beauty** mechanism, not just speed: an expanded reasoning group keeps its local state through permission repartitioning, because refs moving between `refs` and `pending` is a value change on a stable identity — never a remount.
+
+### The membership cutoff — duplicate deltas avoid the aggregate entirely
+
+A route-scoped set contains every visible part identity, including refs nested
+inside groups. A duplicate streaming delta dies at `seenParts.has(id)` before
+reading `state.values()`. Duplicate message and footer events use `Keyed.has`
+against the collection's existing key map. For a replacement that reaches
+`update`, declared equivalence remains the final publication cutoff.
+
+## More Before / After, From the Actual Diff
+
+### Wiring the renderer: one `<For>`, two subscription levels
+
+Before, every row component read its row through the store proxy, and any reconcile could disturb the list:
+
+```tsx title="packages/tui/src/routes/session/index.tsx" caption="Before — one reactive graph for everything"
+<For each={rows}>
+  {(row, index) => (
+    <SessionRowView row={row} ... boundaryID={boundaries()[index()]} />
+  )}
+</For>
+```
+
+After, `KeyedFor` subscribes to the structure channel and each row component subscribes to exactly one slot:
+
+```tsx title="packages/tui/src/routes/session/index.tsx" caption="After — structure and value subscriptions are separate"
+<KeyedFor each={rows.slots}>
+  {(row, index) => (
+    <SessionRowView row={row()} ... boundaryID={boundaries()[index()]} />
+  )}
+</KeyedFor>
+```
+
+### Appending a footer row
+
+```typescript caption="Before — mutate a proxied draft; Solid infers what changed"
+setRows(
+  produce((draft) => {
+    if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
+    const index = queuedStart(draft)
+    completePrevious(draft, index)
+    draft.splice(index, 0, { type: "assistant-footer", messageID })
+  }),
+)
+```
+
+```typescript caption="After — say exactly what happened: maybe one value update, then one insert"
+mutate(() => {
+  if (state.has(footerRowID(messageID))) return
+  const current = state.values()
+  const index = queuedStart(current)
+  complete(current, index)                    // one state.update on the previous group, if open
+  insert(current, index, footerRow(messageID)) // one new slot + one structural publication
+})
+```
+
+Same policy, but the after version *names* its effects: at most one slot value change plus one structural change, flushed together by `Transaction.run`. Nothing has to diff anything to figure that out afterward.
+
+### Removing a row
+
+```typescript caption="Before — linear search, then splice through the proxy"
+setRows(
+  produce((draft) => {
+    const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
+    if (index !== -1) draft.splice(index, 1)
+  }),
+)
+```
+
+```typescript caption="After — remove by identity; the ownership law does the rest"
+mutate(() => {
+  state.remove(footerRowID(messageID))
+})
+```
+
+### Full rebuild (reconnect, revert, compaction)
+
+```typescript caption="Before — reconcile re-derives identity from scratch"
+setRows(reconcile(reduce()))
+```
+
+```typescript caption="After — set() reconciles with declared identity and equivalence"
+setRows(reduce()) // → state.set(next): Map lookups + sameRow checks; unchanged rows publish nothing
+```
+
+This is the one place the two systems do comparable O(N) work — and it's exactly the workload the checked benchmark measured. Every path above it is where Quark does structurally *less*.
+
+## The Cost Ledger
+
+| Per operation | Solid Store (`produce`/`reconcile`) | Quark `Keyed` |
+| --- | --- | --- |
+| Find the row | proxied array walk / identity re-derivation | one `Map.get` |
+| Detect "no change" | proxy-graph diff per touched path | one `equivalent()` call |
+| Value change | proxy writes + per-path invalidation | one signal write → one Solid signal |
+| Structure unchanged | reconcile still walks the list | outer array identity preserved; `<For>` silent |
+| Structure changed | full reconcile | one new array; retained slots reused by reference |
+| Batch of writes | store batching | one `Transaction.run` — settled publication |
+
+Same asymptotics — `O(N)` for a whole-array `set` — but far fewer instructions, allocations, and invalidations per unit of change. **The speedup is a constant-factor win purchased with a stronger contract** (unique stable keys, declared equivalence), not framework magic. The checked-in claim: ~4.7× on sparse value publication, ~6.4× on reorder, at 1,000 rows.
+
+## Architecture Verdict
+
+### What's genuinely good
+
+- **Deep module, tiny surface.** One small `keyed.ts` module carries the whole idea; the laws (unique key, stable key, equivalence, structural cutoff, settled publication, ownership) are explicit and testable.
+- **The adapter is honest.** `useValue` is 8 lines and creates no second reconciliation system — the failure mode the design doc itself warns about.
+- **Identity-as-input is the right call** for this domain: OpenCode *has* real identities (messageID, partID) and was previously throwing that information away for Solid to rediscover.
+- **Falsifiable framing.** The docs predict where Quark should lose. That's rare and worth preserving.
+
+### Where the architecture leaked — all fixed during review
+
+Every leak identified in the first pass has since been closed:
+
+- ~~**`JSON.stringify` keys.**~~ Keys are precomputed `id` fields built once at row construction with length-prefixed segments (`rows.ts` smart constructors); `rowKey` is a field read.
+- ~~**The aggregate channel reintroduces O(N) per delta.**~~ `boundaries` now derives from the structure channel (`rows.slots()`) and reads slot values untracked (quark reads are invisible to Solid's tracker); the raw `values` readable is no longer mirrored into Solid. Per-delta boundary cost is zero; the O(N) recompute fires only on structural change or tracked `messages()` field changes.
+- ~~**Boundary staleness as an implicit invariant.**~~ The untracked-read trick is now type-enforced: `messageBoundaryIDs` accepts `readonly BoundaryRow[]`, a projection of `Pick`ed identity-immutable fields, so a future dependence on a mutable row field is a compile error.
+- ~~**Two grouping engines.**~~ Join-vs-insert policy is unified in `appendDecision` + `groupKind`; both the batch `reduce()` path and the incremental `appendPart` path consume it, and row construction goes through shared smart constructors.
+- ~~**Benchmark not checked in / wrong hot path.**~~ `packages/quark/bench/` now exists and covers incremental `keyed.update` — including against Solid's *direct path write*, the adversarial case — plus dense updates and reorders.
+- ~~**No work counters.**~~ `Keyed` accepts an optional `Metrics` sink; `OPENCODE_QUARK_METRICS=1` reports timeline counters on cleanup.
+- ~~**Opaque positioning and adapter boilerplate.**~~ `insert` and `move` now share an explicit `Position` vocabulary (`"end"`, `before`, or `after`), `get` exposes stable slot addresses, and `KeyedFor` encapsulates the two-level Solid subscription.
+
+The remaining asymmetry — `complete` (immutable `state.update`) vs `completePrevious` (in-place mutation on a plain array under construction) — is inherent to the two contexts, three lines each, and shares the same policy. Not worth abstracting.
+
+None of these are structural flaws — they are integration debts. The layering itself (laws → Keyed → thin adapter → unchanged renderer) is the right shape, and it's the shape worth keeping even if every number in the current doc had to be re-measured.
+
+See `quark-review-recommendations.md` for the prioritized fix list.

+ 550 - 0
docs/design/quark-performance-model.md

@@ -0,0 +1,550 @@
+# Why Quark Can Be Faster Than Solid Store
+
+Status: experimental performance model
+
+## Conclusion
+
+Quark can be faster than the current Solid Store timeline because it accepts a
+stronger contract and uses that contract to do less runtime work.
+
+The advantage does **not** come from TypeScript types by themselves. TypeScript
+types disappear at runtime. The useful information comes from explicit runtime
+inputs and API laws:
+
+- A row key is unique and cannot change during its ownership lifetime.
+- Row equivalence is supplied before updates begin.
+- A keyed list has one stable slot per key.
+- Value changes and structural changes publish through separate channels.
+- A transaction publishes only settled graph state.
+
+Those guarantees remove choices from the hot path. Quark does not need to
+rediscover identity, shape, equivalence, index membership, or ownership on
+every update. Less discovery means fewer comparisons, proxy traps,
+allocations, writes, and downstream invalidations.
+
+This is a conditional claim, not a claim that Quark is universally faster than
+Solid. Solid can be as fast or faster when an application performs a precise
+direct store write, when lists are tiny, or when Quark receives no useful
+identity information. The relevant comparison is the current OpenCode path:
+whole projected records and ordered rows are reconciled into a Solid Store.
+
+## The Physical Argument Is “Do Less Work”
+
+Software cannot escape the physical costs of instructions, memory reads,
+allocations, pointer writes, cache misses, and notification fan-out. An
+abstraction can only become faster by reducing those costs or arranging them
+more favorably.
+
+Quark's proposed advantage has four concrete sources:
+
+1. **Declare repeated decisions once.** Identity and equivalence become
+   preconfigured functions rather than per-event discovery.
+2. **Replace searches with addresses.** Stable keys resolve to persistent
+   slots through a `Map`.
+3. **Separate unrelated change dimensions.** An item value can change without
+   publishing a new list structure.
+4. **Stop propagation early.** Schema equivalence and slot equivalence suppress
+   writes before they reach Solid or the terminal renderer.
+
+These are ordinary computer costs, not framework mythology. If profiling shows
+that Quark executes the same work as Solid plus an adapter, the hypothesis is
+false. If counters show fewer structural publications, fewer component-owner
+reconciliations, and fewer proxy/store operations, the speedup has a mechanical
+explanation.
+
+## Solid Store Must Preserve Generality
+
+The current timeline asks Solid Store to accept ordinary JavaScript objects and
+reconcile new projections into a proxy-backed graph.
+
+```mermaid
+flowchart LR
+    A[Projected rows] --> B[Solid reconcile]
+    B --> C[Inspect keys and shape]
+    C --> D[Proxy-backed Store writes]
+    D --> E[Dependency invalidation]
+    E --> F[For reconciliation]
+    F --> G[SessionRowView]
+```
+
+Solid's generality is valuable. It can react to arbitrary nested property
+access, partial path writes, and plain objects without requiring a schema-owned
+model. That generality also means the runtime must maintain proxy metadata and
+interpret each reconciliation against the current store graph.
+
+Solid is not inherently inefficient. A precise call such as
+`setStore(index, "value", next)` can avoid whole-list reconciliation. OpenCode's
+timeline frequently receives whole projected messages or rebuilt row arrays,
+however, so its current implementation uses `produce` and `reconcile` to
+recover identity and preserve nested owners.
+
+## Quark Makes Identity an Input
+
+`Keyed` receives the identity and equivalence functions when the collection is
+created. In the TUI, every row carries a collision-safe primitive `id`. Groups
+also retain their immutable origin for message-boundary projection:
+
+```typescript
+type PartRef = {
+  messageID: string
+  partID: string
+}
+
+type SessionRow = { readonly id: string } & (
+  | { type: "message"; messageID: string }
+  | { type: "compaction-queued"; inputID: string }
+  | { type: "part"; ref: PartRef }
+  | {
+      type: "group"
+      kind: "reasoning"
+      origin: PartRef
+      refs: PartRef[]
+      completed: boolean
+    }
+  | {
+      type: "group"
+      kind: "exploration"
+      origin: PartRef
+      refs: PartRef[]
+      pending: PartRef[]
+      completed: boolean
+    }
+  | { type: "assistant-footer"; messageID: string }
+)
+```
+
+Factories build the ID once with length-prefixed segments. Length prefixes
+avoid delimiter collisions without invoking a serializer:
+
+```typescript
+function segment(value: string) {
+  return `${value.length}:${value}`
+}
+
+function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRow {
+  const id = `g${kind === "reasoning" ? "r" : "e"}${segment(origin.messageID)}${segment(origin.partID)}`
+  if (kind === "reasoning") return { id, type: "group", kind, origin, refs: [origin], completed: false }
+  return { id, type: "group", kind, origin, refs: [origin], pending: [], completed: false }
+}
+
+function rowKey(row: SessionRow) {
+  return row.id
+}
+```
+
+`sameRow` answers a different question: can consumers distinguish these two
+values for the same identity? It compares only fields that affect row
+rendering.
+
+```typescript
+function sameRow(left: SessionRow, right: SessionRow) {
+  if (left.type !== right.type) return false
+
+  if (left.type === "message" && right.type === "message") return left.messageID === right.messageID
+
+  if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID
+
+  if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref)
+
+  if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID
+
+  if (left.type !== "group" || right.type !== "group") return false
+  if (left.kind !== right.kind) return false
+  if (left.completed !== right.completed) return false
+  if (!sameRefs(left.refs, right.refs)) return false
+
+  if (left.kind === "reasoning" || right.kind === "reasoning") return true
+  return sameRefs(left.pending, right.pending)
+}
+
+function sameRefs(left: PartRef[], right: PartRef[]) {
+  return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index]))
+}
+
+function sameRef(left: PartRef, right: PartRef) {
+  return left.messageID === right.messageID && left.partID === right.partID
+}
+```
+
+Those functions form the complete reconciliation policy:
+
+```typescript
+const rows = Keyed.make<SessionRow, string>({
+  key: rowKey,
+  equivalent: sameRow,
+})
+
+rows.set(nextRows)
+```
+
+It exposes two reactive surfaces:
+
+```typescript
+rows.slots // changes only when keys are inserted, removed, or reordered
+rows.values // changes when any current value changes
+```
+
+Each slot is itself a readable value. Solid `<For>` receives `slots`, so a
+group can grow or complete without replacing its component owner. Generic
+aggregate consumers can subscribe to `values` when they need every value
+change.
+
+The TUI's message-boundary projection deliberately does not subscribe to
+`values`. Boundary identity uses only immutable row fields, so it tracks the
+structural `slots` array and reads each slot value untracked. Value-only deltas
+therefore perform no boundary work; inserts, removals, reorders, or message
+changes recompute boundaries.
+
+Here is a complete value-only transition. The group changes, but its key and
+position do not:
+
+```typescript
+const origin = { messageID: "assistant-1", partID: "call-read" }
+const initial = groupRow("exploration", origin)
+
+rows.set([initial])
+
+const structure = rows.slots()
+const groupSlot = structure[0]
+
+rows.update({
+  ...initial,
+  completed: true,
+})
+
+rows.slots() === structure // true: <For> receives no structural change
+rows.slots()[0] === groupSlot // true: component owner survives
+groupSlot().completed === true // true: row value updated
+```
+
+A structural insertion changes the outer list but preserves every retained
+slot:
+
+```typescript
+const footer: SessionRow = {
+  id: "f11:assistant-1",
+  type: "assistant-footer",
+  messageID: "assistant-1",
+}
+
+const footerSlot = rows.insert(footer)
+
+rows.slots().length === 2 // true
+rows.slots()[0] === groupSlot // true
+rows.slots()[1] === footerSlot // true
+```
+
+Removing and later reinserting a key begins a new ownership lifetime:
+
+```typescript
+rows.remove(footer.id)
+const nextFooterSlot = rows.insert(footer)
+
+nextFooterSlot === footerSlot // false
+```
+
+```mermaid
+flowchart LR
+    A[Next rows] --> B[Keyed.set]
+    B --> C{Same keys and order?}
+    C -->|yes| D[Keep slots array]
+    C -->|no| E[Publish slots array]
+    B --> F{Equivalent value?}
+    F -->|yes| G[No slot write]
+    F -->|no| H[Publish one slot]
+    D --> I[Solid For unchanged]
+    E --> J[Solid For reconciles structure]
+    H --> K[Existing SessionRowView updates]
+```
+
+The key distinction is that **row identity is not inferred from row value**.
+The timeline gives every reasoning or exploration group an immutable creation
+key. Permission partitioning may move refs between `refs` and `pending`, but it
+cannot change the group slot or reset local expanded state.
+
+## The `Keyed.set` Algorithm
+
+The current algorithm is deliberately small:
+
+```text
+set(next):
+  1. Compute every next key and reject duplicates.
+  2. Build Map<key, previousSlot>.
+  3. For each next value:
+       a. Reuse the previous slot for its key, or create one.
+       b. Compare previous and next values.
+       c. Write only a changed slot.
+  4. Publish the outer slot array only if membership or order changed.
+  5. Flush slot and structural writes in one transaction.
+```
+
+The aggregate `values` readable maps the current slots to their values. It is a
+computed node, so it remains lazy without subscribers and publishes one settled
+array when subscribed.
+
+The algorithm preflights duplicate keys before any slot mutation. A rejected
+set therefore cannot partially update the graph. `Map` and `Set` give keys
+SameValueZero semantics, including consistent treatment of `0`/`-0` and
+`NaN`.
+
+## The Laws That Unlock the Optimizations
+
+```definitions
+[
+  {
+    "term": "Unique key law",
+    "definition": "No two current values have the same key. This permits one Map entry and one slot per identity."
+  },
+  {
+    "term": "Stable key law",
+    "definition": "An entity or logical row keeps its key for its lifetime. This permits component ownership and subscriptions to survive value changes."
+  },
+  {
+    "term": "Equivalence law",
+    "definition": "If equivalent(left, right) is true, publishing right cannot change any consumer-visible meaning. This permits early cutoff."
+  },
+  {
+    "term": "Structural cutoff law",
+    "definition": "Value-only updates preserve the exact outer slots-array identity. This prevents keyed-list reconciliation for non-structural changes."
+  },
+  {
+    "term": "Settled publication law",
+    "definition": "Subscribers observe the state after every slot and structural write in the operation, never a partial combination."
+  },
+  {
+    "term": "Ownership law",
+    "definition": "Removing a key removes its slot from the collection. Reinserting that key creates a fresh slot; old external references cannot attach to a new lifetime."
+  }
+]
+```
+
+These laws are stronger than a plain `Array<object>` contract. They are also
+testable. Quark should reject a violated unique-key law and should have direct
+tests for every other law.
+
+## Fixed Layout Research Is Deferred
+
+The standalone laboratory applies the same idea to records. The initial prototype
+used Effect Schema, but its derived equivalence measured `1.621x` the hand
+comparator's direct-update cost. Quark now experiments with a smaller trusted
+in-memory `Layout` that compiles:
+
+- field names into numeric positions;
+- field changes into a bit mask;
+- field equivalence into precomputed functions;
+- entity keys and indexed fields into fixed metadata.
+
+The minimal API marks the key in the shape itself:
+
+```typescript
+const ItemLayout = Layout.struct({
+  id: Layout.key(Layout.number),
+  value: Layout.number,
+})
+
+const ItemPlan = Layout.compile(ItemLayout)
+const items = ItemPlan.make([
+  { id: 1, value: 10 },
+  { id: 2, value: 20 },
+])
+
+items.update({ id: 2, value: 21 })
+```
+
+`ItemPlan` exposes the original field metadata, compiled key field, compiled
+equivalence, and collection factory. Simple struct comparison has measured
+near handwritten speed, but a representative nested session-row union measured
+`1.322x` the handwritten keyed-update cost after closure specialization.
+Generated comparison can remove more indirection, but it is optional research,
+not a requirement for this timeline experiment.
+
+A one-field record replacement can first compute the change mask, then write
+only changed field slots. An indexed-field write knows exactly which index
+buckets to remove from and add to. An entity upsert resolves directly through
+its immutable key.
+
+The static information is therefore not “the compiler knows the type.” It is
+“the runtime has already compiled the layout and the application has agreed to
+obey its laws.” Effect Schema can still validate or transform data at an
+admission boundary without participating in collection updates. None of this
+`Layout`, model, mask, index, or columnar-storage machinery is vendored into the
+TUI fork. The current landing remains the small `Keyed` abstraction plus the
+route reducer; broader promotion requires separate evidence.
+
+## Asymptotics and Constants
+
+For a whole next array of `N` keyed values, both Quark `Keyed.set` and a general
+keyed reconciliation are `O(N)`. Quark does not break a lower bound: it must
+read the next keys to validate and order them.
+
+The observed win is currently a **constant-factor win at the same asymptotic
+complexity**:
+
+- one key extraction per next item; previous slots are already indexed;
+- one `Map` lookup per next item;
+- one explicit equivalence check;
+- one slot write per changed value;
+- one outer write only for changed structure;
+- no general nested proxy reconciliation inside Quark.
+
+For direct collection operations, stronger bounds are possible. An immutable
+key lets `Keyed.update` or `Collection.upsert` find an entity in expected `O(1)`
+time, after which work is proportional to changed fields and affected declared
+indexes rather than total collection size. The TUI now uses event-native
+`update`, `insert`, and `remove` operations for live events; full synchronization
+and revert rebuilds still use linear `set`.
+
+## Controlled Benchmark Evidence
+
+The benchmarks are checked into the fork and run directly:
+
+```bash
+cd packages/quark
+bun run bench:keyed
+bun run bench:row-key
+```
+
+Each invocation interleaves and rotates variants over nine measured samples
+after a discarded warmup. It reports median nanoseconds per operation, median
+absolute deviation, machine-readable metrics, and a checksum.
+
+The original whole-array benchmark established the initial direction:
+
+| Workload                                     | Run |    Quark | Solid Store | Quark / Solid |
+| -------------------------------------------- | --: | -------: | ----------: | ------------: |
+| One value changes in a 1,000-item next array |   1 | 177.2 us |    863.7 us |        0.207x |
+| One value changes in a 1,000-item next array |   2 | 158.8 us |    755.6 us |        0.213x |
+| Reorder a 1,000-item list                    |   1 | 155.3 us |  1,020.8 us |        0.157x |
+| Reorder a 1,000-item list                    |   2 | 147.1 us |    975.6 us |        0.152x |
+
+Across these two invocations:
+
+- keyed value publication was approximately **4.7x-4.8x faster**;
+- keyed reorder was approximately **6.4x-6.6x faster**.
+
+The checksum was stable, and both variants performed the same next-array
+construction inside their timed workloads.
+
+The refreshed integration benchmark measures event-native updates with the
+aggregate `values` channel subscribed, plus adverse workloads. The July 17,
+2026 evidence run produced:
+
+| Workload                                  |       Quark |       Solid | Quark / Solid |
+| ----------------------------------------- | ----------: | ----------: | ------------: |
+| Direct update, no subscribers, 1,000      |     46.8 ns |    761.6 ns |        0.068x |
+| Precise Solid path write, 1,000            |     54.3 ns |    327.5 ns |        0.170x |
+| Subscribed aggregate, 10 rows             |    608.0 ns |      7.4 us |        0.085x |
+| Subscribed aggregate, 100 rows            |      4.4 us |     80.0 us |        0.055x |
+| Subscribed aggregate, 1,000 rows          |     37.4 us |    703.1 us |        0.052x |
+| Subscribed aggregate, 10,000 rows         |    388.5 us |      8.1 ms |        0.044x |
+| Dense 1,000-row update                    |    270.2 us |      2.3 ms |        0.132x |
+| Unstable keys, 100 rows                   |     68.0 us |    245.2 us |        0.282x |
+
+A second independent invocation produced paired ratios of `0.084x`,
+`0.170x`, `0.077x`, `0.059x`, `0.061x`, `0.042x`, `0.130x`, and `0.266x`
+in the same row order. The direct-path result reproduced exactly to three
+decimal places; the other workloads retained the same direction and broad
+magnitude despite normal timing variance.
+
+The aggregate path is still `O(N)`, but it remained substantially cheaper than
+the equivalent Solid Store projection. The precise direct-path case was added
+specifically to favor Solid: it calls `setValues(index, "value", next)` while
+Quark still constructs and compares a replacement object. The predicted Solid
+win did not occur in this environment; Quark measured `0.170x`. This is a
+falsified prediction for this setup, not evidence that Solid can never win a
+direct-write comparison. The production TUI has no aggregate subscriber; it
+tracks structural slots and immutable boundary metadata instead.
+
+Key extraction was measured independently over 10,000 representative rows:
+
+| Key strategy                            |      Median | Paired ratio to JSON |
+| --------------------------------------- | ----------: | -------------------: |
+| `JSON.stringify` tuple                  | 109.6 ns/op |               1.000x |
+| Collision-safe length-prefixed string   |  25.6 ns/op |               0.205x |
+| Precomputed primitive row ID field read |   8.7 ns/op |               0.075x |
+
+The TUI now uses the precomputed primitive ID strategy.
+
+## What the Benchmark Does Not Prove
+
+The suite compares whole-array reconciliation, direct point updates, subscribed
+aggregate projection, dense changes, unstable keys, and key extraction. It
+does not prove that Quark beats:
+
+- every Solid keyed-list primitive;
+- rendering, terminal layout, or paint;
+- all list sizes and mutation distributions;
+- a complete OpenCode session under real provider and tool traffic.
+
+The end-to-end `opencode-drive` runs establish behavioral parity, including
+visible terminal completion and projected reasoning/text content. Their timing
+varied too widely to support a speed claim because it includes provider
+simulation, process scheduling, event transport, terminal rendering, and UI
+polling.
+
+## When Quark Should Lose
+
+The design predicts smaller or negative gains when:
+
+- lists are so small that setup and adapter costs dominate;
+- keys are unstable or expensive to compute;
+- equivalence is more expensive than simply publishing;
+- almost every item and the structure change on every operation;
+- there are no subscribers, so reactive precision has no downstream value;
+- Solid receives a cheaper primitive update while Quark must perform a more
+  expensive projection or whole-array reconciliation;
+- copying the next application-level projection dominates both runtimes;
+- the Quark-to-Solid adapter duplicates work rather than cutting it off.
+
+These are falsifiable predictions. The current adverse suite did not find a
+Solid win, including the newly added precise path write, but it preserves these
+cases so future changes cannot optimize only the favorable sparse path.
+
+## The Next Tests Must Measure Work, Not Just Time
+
+The strongest next experiment is a single-binary A/B implementation driven by
+one deterministic event trace. It should count:
+
+| Deterministic transition       | Slot delta | Structure delta | Observed ownership behavior                 |
+| ------------------------------ | ---------: | --------------: | ------------------------------------------- |
+| First exploration part         |         +0 |              +1 | New group slot                              |
+| Extend exploration group       |         +1 |              +0 | Existing group slot retained                |
+| Permission repartition         |         +1 |              +0 | Existing group slot retained                |
+| Insert queued user row         |         +0 |              +1 | Group remains mounted and incomplete        |
+| Complete promoted-input group  |         +1 |              +0 | Existing group slot retained                |
+| Duplicate text delta           |         +0 |              +0 | Returns before aggregate materialization    |
+| Full unchanged two-row rebuild |         +0 |              +0 | Two equivalence suppressions, no publication |
+
+`Keyed` exposes optional counters for slot publications, structural
+publications, and equivalence suppressions. The focused TUI trace
+`completes exploration when a queued prompt is promoted` records the first five
+transitions and asserts the same slot identity across extension, repartition,
+and completion. `does not publish timeline rows for duplicate streaming
+deltas` records the duplicate path. The direct Keyed law test records the full
+unchanged rebuild. Boundary tests independently assert that value-only changes
+do not invalidate structural boundary projection.
+
+## Decision Rule
+
+Continue the Quark timeline experiment if deterministic replay confirms all of
+the following:
+
+1. Behavior and component ownership remain equivalent to the Solid baseline.
+2. Value-only events publish no structural changes.
+3. CPU or allocation cost improves on the real event distribution.
+4. The Solid adapter remains a thin boundary rather than a second reactive
+   system doing duplicate work.
+5. The reusable `Keyed` and layout laws contain the complexity; TUI code
+   does not grow its own reconciliation engine.
+
+If those conditions fail, the standalone microbenchmarks are not sufficient
+reason to replace Solid Store. If they hold, the speedup is not mysterious:
+Quark is faster because OpenCode supplied stronger information and Quark used
+that information to execute less work.
+
+The current evidence satisfies ownership, structural cutoff, adapter
+thinness, and containment in `Keyed`. Controlled workloads and the
+deterministic TUI trace show lower CPU work for the exercised event shapes.
+That is enough to continue and land the timeline experiment, not to claim a
+universal framework win. Real-session soak remains the check on whether the
+measured event distribution matches production usage.

+ 137 - 0
docs/design/quark-review-recommendations.md

@@ -0,0 +1,137 @@
+# Quark Timeline Experiment: Review Recommendations
+
+Status: point-in-time review feedback; accepted performance and correctness findings implemented
+Reviewed: `docs/design/quark-performance-model.md`, `docs/design/quark-tui-timeline.md`, `packages/quark/src/*`, `packages/tui/src/routes/session/rows.ts`, `packages/tui/src/routes/session/index.tsx`
+
+Goal: really fast and really beautiful. These are ordered recommendations for the next agent. Each item is independently actionable; the order reflects priority.
+
+This document preserves the audit as written. The current implementation has
+checked benchmarks and adverse workloads, structural-only boundaries,
+precomputed IDs, shared grouping policy, filtered permission repartition,
+optional publication counters, deterministic counter tests, and an `O(1)`
+seen-part duplicate path. Current evidence and commands live in
+`quark-performance-model.md` and `quark-tui-timeline.md`; claims below describe
+the pre-fix state. Optional API polish and broader model/index/layout work are
+deferred to avoid expanding the timeline landing.
+
+## 1. Check in the microbenchmark
+
+`quark-performance-model.md` cites concrete numbers (177.2µs vs 863.7µs, etc.) but the benchmark source is not in the tree. Unreproducible numbers are the weakest part of an otherwise careful doc.
+
+- Add the benchmark under `packages/quark/bench/keyed.bench.ts` (or similar).
+- Add the exact repro command to the "Controlled Benchmark Evidence" section of `quark-performance-model.md`.
+- Add the adverse workloads promised in "When Quark Should Lose" (tiny lists, unstable keys, dense mutation, no subscribers, direct `setStore(index, "field", value)` on the Solid side). That section is currently a promise, not a suite.
+
+## 2. Benchmark the actual hot path: incremental `update` with subscribed `values`
+
+The checked-in benchmark story compares `Keyed.set(nextArray)` vs Solid `reconcile(nextArray)`. But after the migration, the streaming hot path is **incremental**: `state.update({...previous, refs: [...refs, ref]})` per delta (`packages/tui/src/routes/session/rows.ts`). Whole-array `set` only runs on reconnect/revert/compaction.
+
+Add a workload: one `update` on a group row, with `values` subscribed, at 100 / 1,000 / 10,000 rows. This measures what the TUI actually does per streaming delta.
+
+## 3. Fix the hidden O(N) per value change through `values` + `boundaries`
+
+The performance-model doc presents `values` as "lazy without subscribers," but in the real integration it is **always subscribed**:
+
+```ts
+// packages/tui/src/routes/session/index.tsx (~line 190)
+const boundaries = createMemo(() => messageBoundaryIDs([...rows.values()], messages()))
+```
+
+So every value-only change that survives `sameRow` pays:
+
+- O(N) recompute of the `values` computed (N slot reads + dependency tracking, `packages/quark/src/keyed.ts:22-25`)
+- O(N) `same` compare
+- O(N) array spread in the memo
+- a full `messageBoundaryIDs` recompute
+
+The structural cutoff for `<For>` is real, but this is the remaining per-delta O(N) path. Options, in increasing ambition:
+
+1. Change `messageBoundaryIDs` to accept `readonly SessionRow[]` so the spread copy dies (trivial).
+2. Derive boundaries from `slots` plus per-slot reads so a group value change cannot invalidate them.
+3. Maintain boundary info incrementally inside the same mutate operations that change structure.
+
+At minimum, the doc should state the real cost with a measured number instead of implying laziness the integration doesn't have.
+
+## 4. Replace `JSON.stringify` row keys with cheap concatenation
+
+`rowKey` calls `JSON.stringify` per row per `set` (`packages/tui/src/routes/session/rows.ts:439-445`). On a 10,000-row `set` that's 10,000 serializer calls plus allocations, working directly against the "compile repeated decisions once" thesis.
+
+Use a delimiter that cannot appear in IDs:
+
+```ts
+function rowKey(row: SessionRow) {
+  if (row.type === "message") return `message\x00${row.messageID}`
+  if (row.type === "compaction-queued") return `compaction-queued\x00${row.inputID}`
+  if (row.type === "part") return `part\x00${row.ref.messageID}\x00${row.ref.partID}`
+  if (row.type === "assistant-footer") return `assistant-footer\x00${row.messageID}`
+  return `group\x00${row.kind}\x00${row.key.messageID}\x00${row.key.partID}`
+}
+```
+
+Re-measure after; expect a meaningful constant-factor win on key extraction. Update the code sample in `quark-performance-model.md` to match.
+
+## 5. Unify the two grouping-policy engines
+
+There are now two implementations of the row grouping rules:
+
+- The batch rebuild path: `reduce()` builds a plain array via the old `append` / `completePrevious` helpers (`rows.ts:385+`), then calls `state.set`.
+- The incremental path: `appendPart` / `appendMessage` / `complete` closures reimplement the same join/complete decisions inline (`rows.ts:147-215`).
+
+The performance-model doc's own decision rule #5 says "TUI code does not grow its own reconciliation engine" — this duplication is exactly that risk. Extract the shared decisions as pure functions, e.g. `joinsPreviousGroup(previous, part)` and `newRowFor(part, ref)`, and have both paths consume them. Do not lose the incremental path's precision; only deduplicate the policy.
+
+## 6. Filter before cloning in permission repartition
+
+The pending-permissions effect clones **every** exploration group on every change before `sameRow` suppresses the no-ops (`rows.ts:83-91`):
+
+```ts
+state.values().forEach((row) => {
+  if (row.type !== "group" || row.kind !== "exploration") return
+  const next = { ...row, refs: [...row.refs], pending: [...row.pending] }
+  partitionPending([next], pending)
+  state.update(next)
+})
+```
+
+Filter to groups whose refs actually intersect the pending set before cloning. The clone-then-compare pattern spends the allocations the equivalence law was supposed to save.
+
+## 7. Add work counters to `Keyed`
+
+The strongest section of the performance model is "The Next Tests Must Measure Work, Not Just Time" — but `Keyed` has no instrumentation, so none of that table can be produced today.
+
+Add optional counters behind a debug flag in `packages/quark/src/keyed.ts`:
+
+- slot publications (value writes)
+- structural publications (outer `slots` writes)
+- equivalence suppressions (writes avoided by `equivalent`)
+
+Then run the deterministic trace via `script/quark-timeline-drive.ts` and fill in the expected-behavior table with real counts. This converts the decision rule from vibes to numbers.
+
+## 8. Demonstrate the "beautiful" claim with a recording
+
+The user-visible payoff — stable component ownership means no flicker, no reset of expanded reasoning groups, no scroll jumps during permission repartition — is asserted but never demonstrated.
+
+Capture a terminal-control recording of an expanded group surviving a permission repartition on this branch vs untouched `origin/v2`. That artifact is more persuasive than any benchmark table and belongs alongside it in the doc.
+
+## 9. Small API and code polish
+
+- ~~`insert(value, { before? })` where `before: undefined` means append reads poorly at call sites.~~ Fixed with the shared `Position<Key>` vocabulary: `"end"`, `{ before: key }`, or `{ after: key }`.
+- The duplicate-key error in `Keyed.set` should name the offending key (`packages/quark/src/keyed.ts:32`).
+- `update` mixes contracts: throws on missing key, returns `false` on no-op. That's defensible (missing key is a programmer error) but should be documented as a law.
+- Doc correction in `quark-performance-model.md`, "Asymptotics and Constants": it claims "one key extraction per previous and next item," but the implementation only extracts **next** keys; previous slots are reached through the `byKey` map (`keyed.ts:30-52`). Fix the claim.
+
+## What is already good (do not churn)
+
+- The two-channel `slots` / `values` surface plus verbs is a genuinely clean API. Keep it.
+- The laws section and the honest "When Quark Should Lose" / "What the Benchmark Does Not Prove" framing are correct and rare. Preserve that tone.
+- The experimental boundary (only the row owner changes; `DataProvider`, reduction rules, and `SessionRowView` untouched) is right. Do not widen it while acting on these items.
+- Duplicate-key preflight before any mutation (no partial updates) is correct; keep it.
+
+## Suggested execution order
+
+1. Check in benchmark + adverse workloads (items 1, 2)
+2. Cheap key extraction, re-measure (item 4)
+3. Unify grouping engines (item 5)
+4. Counters + deterministic drive trace (item 7)
+5. Decide the `values`/boundaries O(N) question with data (item 3)
+6. Repartition filtering (item 6)
+7. Recording artifact + doc corrections + polish (items 8, 9)

+ 426 - 0
docs/design/quark-tui-timeline.md

@@ -0,0 +1,426 @@
+# Quark-Owned TUI Timeline Rows
+
+Status: experiment in progress
+
+## Summary
+
+The V2 TUI currently stores its rendered session-row list in a Solid Store.
+This experiment changes only that owner: `createSessionRows` stores an ordered
+array of stable row slots in Quark state. Each slot owns one `SessionRow`, and
+an owner-aware adapter exposes both levels to the existing Solid renderer.
+
+The event protocol, durable session data, `DataProvider`, row reduction rules,
+and `SessionRowView` remain unchanged. This boundary makes the experiment easy
+to compare and easy to remove. It does not yet move messages or secondary
+indexes into Quark collections.
+
+The experiment succeeds only if all three checks pass:
+
+1. Existing TUI timeline tests preserve their behavior.
+2. One `opencode-drive` script passes unchanged against untouched `origin/v2`
+   and this branch.
+3. The Quark path improves measured timeline work without increasing visible
+   latency or invalidation counts.
+
+## The Row Owner Is the Experimental Boundary
+
+`DataProvider` receives server events and projects them into its existing Solid
+Store. `createSessionRows` reads that projection, subscribes to timeline events,
+and maintains the ordered rows consumed by `SessionRowView`.
+
+Before this branch, `createSessionRows` also used a Solid Store for its row
+state:
+
+```text
+server events
+    |
+    v
+DataProvider Solid Store
+    |
+    v
+createSessionRows
+    |
+    v
+SessionRow[] Solid Store
+    |
+    v
+<For> -> SessionRowView
+```
+
+This branch replaces only the second store:
+
+```text
+server events
+    |
+    v
+DataProvider Solid Store
+    |
+    v
+createSessionRows
+    |
+    v
+Quark State<RowSlot[]>
+    |
+    v
+KeyedFor outer + slot adapters
+    |
+    v
+SessionRowView
+```
+
+The narrow boundary answers one question: does Quark improve the TUI timeline
+when it owns the ordered render rows? Moving message storage at the same time
+would make a regression or improvement impossible to attribute.
+
+## Components and Responsibilities
+
+| Component                                   | Responsibility                                                                                      |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| `packages/tui/src/context/data.tsx`         | Preserve the existing server-event projection and message lookup API.                               |
+| `packages/tui/src/routes/session/rows.ts`   | Reduce loaded messages, apply incremental timeline events, and own ordered rows.                    |
+| `packages/quark/src/reactivity.ts`          | Provide synchronous state updates, subscriptions, and transaction batching.                         |
+| `packages/quark/src/keyed.ts`               | Reuse per-key slots and separate value changes from structural changes.                             |
+| `packages/quark/src/solid.ts`               | Bridge Quark readables into Solid ownership and compose stable slots with `KeyedFor`.               |
+| `packages/tui/src/routes/session/index.tsx` | Render stable row accessors through `KeyedFor` and the existing `SessionRowView` components.        |
+| `script/quark-timeline-drive.ts`            | Exercise the same streamed timeline scenario against baseline and fork binaries.                    |
+
+## Quark State Publishes One Synchronous Value
+
+`State.make(initial)` wraps an `alien-signals` signal with three operations:
+
+```ts
+read() // track and return the current value
+read.set(next) // publish one replacement value
+read.update(f) // derive and publish from the current value
+```
+
+`subscribe` creates an `alien-signals` effect. The effect reads the signal once
+to establish its dependency without calling the listener, because `useValue`
+already reads the initial value. Later signal writes rerun the effect
+synchronously and call the listener once with the published value.
+
+Before invoking the listener, `subscribe` clears alien-signals' active
+subscriber and restores it afterward. Reads performed by the listener or by a
+synchronous Solid update therefore cannot become accidental dependencies of
+the Quark subscription.
+
+Transactions call `startBatch()` and `endBatch()`. Row reconciliation can
+update several slots plus the outer slot array, so the row owner uses one
+transaction for the complete publication.
+
+## `useValue` Crosses the Reactive Boundary Once
+
+`useValue(readable)` creates one Solid signal initialized from `readable()`. It
+then subscribes to Quark and forwards each published value into that Solid
+signal. The timeline uses this algorithm for the structural slot list and each
+mounted row slot. Its aggregate `values` readable remains inside Quark and is
+not mirrored into Solid:
+
+```text
+outer Quark State.set(nextSlots)
+          |
+          v
+alien-signals subscription
+          |
+          v
+Solid setSignal(nextSlots)
+          |
+          v
+rowSlots() -> <For>
+                    |
+                    v
+             slot Quark State.set(nextRow)
+                    |
+                    v
+             row() -> SessionRowView
+```
+
+The adapter passes `() => nextRows` to Solid's setter. Solid treats a bare
+function as an updater, so the wrapper is required when the reactive value
+itself could be a function. The same generic adapter therefore works for both
+arrays and callable values.
+
+## Full Rebuilds Use a Deterministic Reducer
+
+Connection, synchronization, revert, pending-compaction, and message-boundary
+changes rebuild rows from the current `DataProvider` projection. The rebuild
+algorithm is:
+
+```text
+messages = loaded messages before the revert boundary
+inputs = admitted input message IDs
+pending = running compaction IDs + input IDs
+
+ordered messages =
+  completed messages
+  + running compactions
+  + admitted inputs
+
+for each ordered message:
+  non-assistant -> append one message row
+  assistant     -> append/group each non-empty content part
+  finished assistant -> append one footer row
+
+partition exploration refs by pending permission call IDs
+insert queued compactions before the first pending input row
+```
+
+The first partition puts completed transcript rows before work that has not
+crossed its safe execution boundary. A non-pending message completes the group
+immediately before it. This prevents reasoning or exploration groups from
+visually spanning separate transcript messages.
+
+Assistant text and reasoning parts use their ordinal among parts of the same
+type as their stable part ID, such as `text:0` or `reasoning:1`. Tool parts use
+their call ID. Empty text and reasoning parts do not create rows.
+
+## Incremental Events Avoid Full Reduction
+
+Most live events update the current row array directly:
+
+| Event shape                                                     | Row operation                                                   |
+| --------------------------------------------------------------- | --------------------------------------------------------------- |
+| New user, synthetic, shell, model, agent, or compaction message | Insert a message row unless its ID already exists.              |
+| First non-empty text or reasoning delta                         | Insert or extend the corresponding part/group row.              |
+| Tool input started                                              | Insert a tool row or extend an exploration group.               |
+| Step ended, failed, or retry scheduled                          | Insert an assistant footer.                                     |
+| Step started                                                    | Remove the previous retry footer.                               |
+| Permission changed                                              | Repartition exploration refs between visible and pending lists. |
+
+`queuedStart(rows)` finds the first queued compaction or pending message. New
+assistant parts and completed messages insert before that boundary. Pending
+user inputs append after queued work. A running compaction inserts at the start
+of the queued region.
+
+`append(rows, ref, part, index)` implements grouping:
+
+```text
+reasoning part:
+  extend the immediately preceding reasoning group
+  or complete the preceding group and create a reasoning group
+
+read/glob/grep tool:
+  extend the immediately preceding exploration group
+  or complete the preceding group and create an exploration group
+
+other text or tool part:
+  complete the preceding group and insert a standalone part row
+```
+
+The grouping decision depends on the tool name, not the call ID. This matters
+because call IDs can look like generated text or reasoning IDs.
+
+## Incremental Mutations Touch Only Affected Slots
+
+The old Solid `produce` path created a mutable draft of the row list. The first
+Quark implementation also rebuilt a complete next array, then reconciled it.
+The event-native path now uses the information already present in each event:
+
+```text
+value change       -> Keyed.update(row)
+new row            -> Keyed.insert(row, { before })
+removed footer     -> Keyed.remove(row.id)
+full sync / revert -> Keyed.set(nextRows)
+```
+
+Extending or completing a group creates one new group value with copied refs
+and writes its existing slot. It does not copy unrelated groups or publish
+structure. Permission repartitioning first checks whether a group's visible or
+pending membership would actually change, then allocates arrays only for those
+groups.
+
+Duplicate message, part, or footer events return before a Keyed operation.
+Same-ordinal streaming deltas therefore produce no slot or structural
+publication after the first part.
+
+## `Keyed` Preserves Row Identity
+
+A full rebuild creates fresh row values, but Solid `<For>` uses item identity
+to preserve child ownership. A changed group value must not remount its
+`SessionRowView`, because that would reset the group's local expanded and hover
+state. `<For>` therefore receives stable Quark slots rather than row values.
+
+`Keyed.set(nextRows)` preserves ownership in four passes:
+
+```text
+1. Index previous slots by the semantic key of their current row.
+2. For each next row, find the previous slot with that key.
+3. Update that slot only when the rendered row fields changed.
+4. Reuse the previous outer slot array when membership and order are unchanged.
+```
+
+Every row receives one collision-safe length-prefixed primitive ID when it is
+created. `Keyed` reads `row.id`; reconciliation performs no serialization:
+
+| Row               | ID shape                                              |
+| ----------------- | ----------------------------------------------------- |
+| Message           | `m{length}:{messageID}`                               |
+| Queued compaction | `c{length}:{inputID}`                                 |
+| Part              | `p{message segment}{part segment}`                    |
+| Assistant footer  | `f{length}:{messageID}`                               |
+| Group             | `g{kind}{origin message segment}{origin part segment}` |
+
+Every group records the ref that created it as immutable `origin` metadata.
+Appending refs, completing the group, and moving refs between permission
+partitions cannot change its ID or boundary identity. Group equivalence compares
+`completed`, every `refs` entry, and every exploration `pending` entry.
+
+`Keyed` publishes two readables. `slots` changes only when key membership or
+order changes, so Solid `<For>` does no structural work for a group-value
+update. `values` depends on both the outer list and every slot for consumers
+that require aggregate values. The TUI has no reactive `values` subscriber; it
+uses the lazy aggregate only as an internal snapshot for unique mutation paths.
+Message boundaries track structural slots and messages, then read immutable
+row identity fields untracked. A changed group publishes through its existing
+slot and preserves the mounted Solid owner without recalculating boundaries.
+
+Slot updates and the outer-array update run inside both a Quark transaction and
+a Solid batch. The Quark transaction settles the alien-signals graph; the
+surrounding Solid batch holds adapter writes until every Quark listener has
+flushed. Solid therefore observes one settled state rather than a row value
+from one ordering and an outer slot array from another.
+
+## Complexity Is Linear in Rows and Contained Refs
+
+Let `R` be visible rows, `M` loaded messages, `P` assistant content parts, and
+`C` the total refs contained by visible groups.
+
+| Operation                  |                     Time |                                      Allocation |
+| -------------------------- | -----------------------: | ----------------------------------------------: |
+| Full rebuild               |           `O(M + P + R)` |        New reduction plus reconciled slot array |
+| Unique incremental append  |               `O(R + C)` | One structural array or one changed group array |
+| Duplicate part append      |                   `O(1)` |                                            None |
+| Duplicate message/footer   |                   `O(1)` |                                            None |
+| Footer removal             |                   `O(R)` |                       One structural slot array |
+| Permission repartition     |               `O(R + C)` |        Arrays only for groups whose split moves |
+| Quark-to-Solid publication | `O(1)` per changed level |  One slot write and, when required, outer write |
+
+`Keyed.has` handles message and footer membership through the collection's
+existing key map. A route-scoped `Set` contains every visible part ID, including
+refs nested inside groups. Full rebuilds repopulate it and unique appends add to
+it once. Duplicate streaming deltas therefore return before reading the lazy
+aggregate or scanning rows. Unique appends still materialize the current row
+snapshot and scan for the queued boundary; that lower-frequency path remains
+linear deliberately rather than introducing a secondary-index layer.
+
+## Solid Owns the Adapter Lifecycle
+
+`createSessionRows` runs inside the Solid owner for the session route.
+`useValue` reads the initial Quark value, subscribes once, and registers the
+unsubscribe function with Solid's `onCleanup`.
+
+The existing event-bus subscriptions use the same owner cleanup. Leaving the
+route therefore removes both the Quark-to-Solid subscription and all timeline
+event subscriptions. Quark state does not outlive the route.
+
+## Behavior Must Remain Equivalent
+
+The experiment preserves these existing timeline rules:
+
+- User, synthetic, compaction, shell, model, and agent messages retain their
+  current ordering.
+- Pending inputs and queued compactions remain after active output.
+- Reasoning and exploration parts retain their grouping behavior.
+- Exploration tool calls move between pending and completed partitions when
+  permissions change.
+- Retry footers appear and disappear on the same events.
+- Duplicate message, part, and footer events remain idempotent.
+- Revert boundaries and message synchronization still rebuild the complete row
+  list from `DataProvider`.
+- `SessionRowView` continues to resolve message content from `DataProvider`;
+  Quark owns row structure, not message content.
+
+Changing `createSessionRows` from an array-like Solid Store to structural
+`slots` plus an internal lazy aggregate is an internal API change. The session
+route and its data-test probes are the only consumers on this branch.
+
+## The Comparison Uses One Drive Script
+
+The baseline is a detached worktree at
+`/Users/kit/code/open-source/opencode-quark-baseline`, pinned to the same
+`origin/v2` commit as the fork. The fork is
+`/Users/kit/code/open-source/opencode-quark-timeline`.
+
+`script/quark-timeline-drive.ts` creates one isolated project, submits one
+prompt, streams one long reasoning span and one long text span as many delta
+events, and waits for the final `QUARK_TIMELINE_COMPLETE` marker. The script
+prints elapsed wall time and uses marker visibility as its rendered correctness
+assertion. This workload stresses duplicate-part handling after the first
+delta; it does not create 120 distinct timeline rows.
+
+After the marker renders, the script queries the same isolated service and
+asserts that the projected assistant message contains the complete reasoning
+span, complete text span, and `stop` finish. The API assertion covers content
+that has scrolled outside the terminal viewport.
+
+Run the same checked script against each worktree:
+
+```sh
+cd /Users/kit/code/open-source/opencode-drive
+
+packages/drive/bin/opencode-drive check \
+  /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts
+
+packages/drive/bin/opencode-drive start \
+  --name quark-timeline-baseline \
+  --script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \
+  --dev /Users/kit/code/open-source/opencode-quark-baseline
+
+packages/drive/bin/opencode-drive start \
+  --name quark-timeline-fork \
+  --script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \
+  --dev /Users/kit/code/open-source/opencode-quark-timeline
+```
+
+Wall time from one end-to-end drive run is supporting evidence, not a stable
+microbenchmark. Provider simulation, terminal polling, and process scheduling
+also contribute to it. A performance decision requires repeated paired runs
+and row-update or invalidation counters in addition to this behavioral check.
+
+Three alternating smoke pairs completed successfully:
+
+|                   Pair |  Baseline | Quark fork | Fork / baseline |
+| ---------------------: | --------: | ---------: | --------------: |
+|                      1 | 11,505 ms |   6,455 ms |          0.561x |
+|                      2 |  4,003 ms |   4,733 ms |          1.182x |
+|          3, fork first |  9,296 ms |   7,563 ms |          0.813x |
+|      4, API assertions |  4,283 ms |   3,114 ms |          0.727x |
+| 5, current `origin/v2` |  3,636 ms |   2,074 ms |          0.570x |
+| 6, current Drive API   |  2,233 ms |   1,413 ms |          0.633x |
+
+The range is too wide to support a speed claim. These runs establish that the
+same streamed scenario renders and terminates on both implementations. They do
+not establish that either implementation is faster.
+
+## Validation Status
+
+| Check                                | Current result                                                             |
+| ------------------------------------ | -------------------------------------------------------------------------- |
+| TUI package typecheck                | Pass                                                                       |
+| Full TUI test suite                  | 267 pass, 1 skip                                                           |
+| Focused data and row tests           | 48 pass                                                                    |
+| Vendored Quark tests                 | 13 pass                                                                    |
+| Shared drive scripts typecheck       | Pass                                                                       |
+| Shared Drive behavior                | Six baseline/fork pairs pass; latest pair verifies projected content       |
+| Publication-counter trace            | Pass; exact slot/structure deltas recorded in performance model            |
+| Paired performance evidence          | Pass for controlled workloads; end-to-end wall time remains non-conclusive |
+
+## Collection Cache Growth Is Outside This Phase
+
+The standalone `effect-quark` prototype includes reactive missing-key and
+secondary-index query caches without eviction. This branch vendors only Quark
+state and the Solid adapter; it does not create those caches.
+
+A later message-collection phase must not delete empty cache entries while a
+live readable still references them. Safe eviction therefore needs explicit
+collection ownership or subscription reference counting. That design is not a
+prerequisite for the route-scoped row-state experiment.
+
+## Rollback Is One Seam
+
+Rollback removes the private `effect-quark` workspace package and restores the
+Solid Store in `createSessionRows`. No protocol, persisted data, public client
+API, or server projection changes need migration.
+
+The experiment should not expand into `DataProvider` message storage until the
+current boundary has behavior parity and repeatable performance evidence.

+ 60 - 0
packages/quark/bench/harness.ts

@@ -0,0 +1,60 @@
+export type Workload = {
+  run(index: number): void
+  consume(): number
+  dispose?(): void
+}
+
+export type Variant = {
+  readonly name: string
+  readonly make: () => Workload
+}
+
+export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) {
+  const samples = options.samples ?? 9
+  const warmup = options.warmup ?? 500
+  let checksum = 0
+
+  return {
+    samples,
+    compare(iterations: number, variants: readonly Variant[]) {
+      const timings = variants.map(() => [] as number[])
+      for (let sample = -1; sample < samples; sample++) {
+        const offset = sample < 0 ? 0 : sample % variants.length
+        variants
+          .map((_variant, index) => (index + offset) % variants.length)
+          .forEach((variantIndex) => {
+            const workload = variants[variantIndex].make()
+            for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index)
+            const start = Bun.nanoseconds()
+            for (let index = 0; index < iterations; index++) workload.run(index)
+            const elapsed = Bun.nanoseconds() - start
+            checksum += workload.consume()
+            workload.dispose?.()
+            if (sample >= 0) timings[variantIndex].push(elapsed / iterations)
+          })
+      }
+
+      const medians = variants.map((variant, index) => {
+        const median = middle(timings[index])
+        const mad = middle(timings[index].map((value) => Math.abs(value - median)))
+        const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")
+        console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op  +/- ${mad.toFixed(1)} MAD`)
+        console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`)
+        return median
+      })
+      return {
+        medians,
+        ratio(left: number, right: number) {
+          return middle(timings[left].map((value, index) => value / timings[right][index]))
+        },
+      }
+    },
+    finish() {
+      console.log(`CHECKSUM ${checksum}`)
+    },
+  }
+}
+
+function middle(values: number[]) {
+  return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
+}

+ 217 - 0
packages/quark/bench/keyed.ts

@@ -0,0 +1,217 @@
+import { createComputed, createRoot } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { Keyed } from "../src"
+import { createHarness, type Workload } from "./harness"
+
+type Item = {
+  readonly id: number
+  readonly value: number
+}
+
+const bench = createHarness()
+const results: Array<{ readonly name: string; readonly ratio: number }> = []
+
+function initial(size: number) {
+  return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 }))
+}
+
+function project(values: readonly Item[]) {
+  return values.reduce((total, value) => total + value.id + value.value, 0)
+}
+
+function quarkDirect(size: number, aggregate: boolean): Workload {
+  const values = initial(size)
+  const keyed = Keyed.make<Item, number>({
+    key: (item) => item.id,
+    equivalent: (left, right) => left.value === right.value,
+  })
+  keyed.set(values)
+  const target = keyed.slots()[Math.floor(size / 2)]
+  let sink = aggregate ? project(keyed.values()) : target().value
+  const dispose = aggregate
+    ? keyed.values.subscribe((next) => (sink = project(next)))
+    : target.subscribe((value) => (sink = value.value))
+  return {
+    run: (index) => keyed.update({ id: Math.floor(size / 2), value: index + 1 }),
+    consume: () => sink,
+    dispose,
+  }
+}
+
+function solidDirect(size: number, aggregate: boolean): Workload {
+  let run = (_index: number) => {}
+  let consume = () => 0
+  let dispose = () => {}
+  createRoot((rootDispose) => {
+    dispose = rootDispose
+    const [values, setValues] = createStore(initial(size))
+    const target = Math.floor(size / 2)
+    let sink = aggregate ? project(values) : values[target].value
+    if (aggregate) createComputed(() => (sink = project(values)))
+    else createComputed(() => (sink = values[target].value))
+    run = (index) => setValues(target, reconcile({ id: target, value: index + 1 }))
+    consume = () => sink
+  })
+  return { run, consume, dispose }
+}
+
+function quarkNoSubscriber(size: number): Workload {
+  const keyed = Keyed.make<Item, number>({
+    key: (item) => item.id,
+    equivalent: (left, right) => left.value === right.value,
+  })
+  keyed.set(initial(size))
+  const target = Math.floor(size / 2)
+  return {
+    run: (index) => keyed.update({ id: target, value: index + 1 }),
+    consume: () => keyed.slots()[target]().value,
+  }
+}
+
+function solidNoSubscriber(size: number): Workload {
+  const [values, setValues] = createStore(initial(size))
+  const target = Math.floor(size / 2)
+  return {
+    run: (index) => setValues(target, reconcile({ id: target, value: index + 1 })),
+    consume: () => values[target].value,
+  }
+}
+
+function solidPathWriteNoSubscriber(size: number): Workload {
+  const [values, setValues] = createStore(initial(size))
+  const target = Math.floor(size / 2)
+  return {
+    run: (index) => setValues(target, "value", index + 1),
+    consume: () => values[target].value,
+  }
+}
+
+function quarkDense(size: number): Workload {
+  const keyed = Keyed.make<Item, number>({
+    key: (item) => item.id,
+    equivalent: (left, right) => left.value === right.value,
+  })
+  keyed.set(initial(size))
+  let sink = project(keyed.values())
+  const dispose = keyed.values.subscribe((values) => (sink = project(values)))
+  return {
+    run: (index) => keyed.set(initial(size).map((item) => ({ ...item, value: index + 1 }))),
+    consume: () => sink,
+    dispose,
+  }
+}
+
+function solidDense(size: number): Workload {
+  let run = (_index: number) => {}
+  let consume = () => 0
+  let dispose = () => {}
+  createRoot((rootDispose) => {
+    dispose = rootDispose
+    const [values, setValues] = createStore(initial(size))
+    let sink = project(values)
+    createComputed(() => (sink = project(values)))
+    run = (index) => setValues(reconcile(initial(size).map((item) => ({ ...item, value: index + 1 }))))
+    consume = () => sink
+  })
+  return { run, consume, dispose }
+}
+
+function quarkUnstable(size: number): Workload {
+  const keyed = Keyed.make<Item, number>({ key: (item) => item.id })
+  keyed.set(initial(size))
+  let sink = project(keyed.values())
+  const dispose = keyed.values.subscribe((values) => (sink = project(values)))
+  return {
+    run(index) {
+      const offset = (index + 1) * size
+      keyed.set(initial(size).map((item) => ({ id: item.id + offset, value: index })))
+    },
+    consume: () => sink,
+    dispose,
+  }
+}
+
+function solidUnstable(size: number): Workload {
+  let run = (_index: number) => {}
+  let consume = () => 0
+  let dispose = () => {}
+  createRoot((rootDispose) => {
+    dispose = rootDispose
+    const [values, setValues] = createStore(initial(size))
+    let sink = project(values)
+    createComputed(() => (sink = project(values)))
+    run = (index) => {
+      const offset = (index + 1) * size
+      setValues(reconcile(initial(size).map((item) => ({ id: item.id + offset, value: index }))))
+    }
+    consume = () => sink
+  })
+  return { run, consume, dispose }
+}
+
+function compare(name: string, iterations: number, quark: () => Workload, solid: () => Workload) {
+  console.log(`\n${name}`)
+  const result = bench.compare(iterations, [
+    { name: `Quark ${name}`, make: quark },
+    { name: `Solid ${name}`, make: solid },
+  ])
+  results.push({ name, ratio: result.ratio(0, 1) })
+}
+
+console.log(`Keyed integration benchmark (${bench.samples} samples)`)
+
+compare(
+  "direct no subscribers 1000",
+  200_000,
+  () => quarkNoSubscriber(1_000),
+  () => solidNoSubscriber(1_000),
+)
+compare(
+  "adversarial direct path write 1000",
+  200_000,
+  () => quarkNoSubscriber(1_000),
+  () => solidPathWriteNoSubscriber(1_000),
+)
+compare(
+  "subscribed values 10",
+  100_000,
+  () => quarkDirect(10, true),
+  () => solidDirect(10, true),
+)
+compare(
+  "subscribed values 100",
+  25_000,
+  () => quarkDirect(100, true),
+  () => solidDirect(100, true),
+)
+compare(
+  "subscribed values 1000",
+  2_500,
+  () => quarkDirect(1_000, true),
+  () => solidDirect(1_000, true),
+)
+compare(
+  "subscribed values 10000",
+  250,
+  () => quarkDirect(10_000, true),
+  () => solidDirect(10_000, true),
+)
+compare(
+  "dense update 1000",
+  250,
+  () => quarkDense(1_000),
+  () => solidDense(1_000),
+)
+compare(
+  "unstable keys 100",
+  1_000,
+  () => quarkUnstable(100),
+  () => solidUnstable(100),
+)
+
+console.log("\nRatios to Solid (lower is faster)")
+results.forEach((result) => {
+  console.log(`${result.name.padEnd(34)} ${result.ratio.toFixed(3)}x`)
+  console.log(`METRIC ${result.name.replaceAll(/[^a-z0-9]+/g, "_")}_ratio=${result.ratio.toFixed(6)}`)
+})
+bench.finish()

+ 63 - 0
packages/quark/bench/row-key.ts

@@ -0,0 +1,63 @@
+import { createHarness, type Workload } from "./harness"
+
+type Row =
+  | { readonly type: "message"; readonly messageID: string }
+  | { readonly type: "part"; readonly messageID: string; readonly partID: string }
+  | {
+      readonly type: "group"
+      readonly kind: "reasoning" | "exploration"
+      readonly messageID: string
+      readonly partID: string
+    }
+
+const size = 10_000
+const iterations = 1_000_000
+const rows = Array.from({ length: size }, (_, index): Row => {
+  if (index % 3 === 0) return { type: "message", messageID: `message-${index}` }
+  if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` }
+  return {
+    type: "group",
+    kind: index % 2 === 0 ? "reasoning" : "exploration",
+    messageID: `message-${index >> 2}`,
+    partID: `call-${index}`,
+  }
+})
+const precomputed = rows.map((row) => ({ row, id: concatenate(row) }))
+const bench = createHarness()
+
+function workload(read: (index: number) => string): Workload {
+  let sink = 0
+  return {
+    run(index) {
+      sink += read(index % size).length
+    },
+    consume: () => sink,
+  }
+}
+
+function json(row: Row) {
+  if (row.type === "message") return JSON.stringify([row.type, row.messageID])
+  if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID])
+  return JSON.stringify([row.type, row.kind, row.messageID, row.partID])
+}
+
+function concatenate(row: Row) {
+  if (row.type === "message") return `m${row.messageID.length}:${row.messageID}`
+  if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
+  return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
+}
+
+console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`)
+
+const result = bench.compare(iterations, [
+  { name: "JSON tuple key", make: () => workload((index) => json(rows[index])) },
+  { name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) },
+  { name: "Precomputed key", make: () => workload((index) => precomputed[index].id) },
+])
+
+console.log("\nRatios to JSON tuple (lower is faster)")
+console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`)
+console.log(`Precomputed:  ${result.ratio(2, 0).toFixed(3)}x`)
+console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`)
+console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`)
+bench.finish()

+ 20 - 0
packages/quark/package.json

@@ -0,0 +1,20 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "effect-quark",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "exports": {
+    ".": "./src/index.ts",
+    "./solid": "./src/solid.ts"
+  },
+  "scripts": {
+    "bench:keyed": "bun --conditions=browser bench/keyed.ts",
+    "bench:row-key": "bun --conditions=browser bench/row-key.ts",
+    "test": "bun --conditions=browser test"
+  },
+  "dependencies": {
+    "alien-signals": "3.2.1",
+    "solid-js": "catalog:"
+  }
+}

+ 2 - 0
packages/quark/src/index.ts

@@ -0,0 +1,2 @@
+export { Keyed } from "./keyed"
+export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"

+ 143 - 0
packages/quark/src/keyed.ts

@@ -0,0 +1,143 @@
+import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
+
+export namespace Keyed {
+  export type Position<Key> = "end" | { readonly before: Key } | { readonly after: Key }
+
+  export interface Metrics {
+    slotPublications: number
+    structuralPublications: number
+    equivalenceSuppressions: number
+  }
+
+  export interface Keyed<A, Key> {
+    readonly slots: Readable<readonly Readable<A>[]>
+    readonly values: Readable<readonly A[]>
+    has(key: Key): boolean
+    get(key: Key): Readable<A> | undefined
+    set(values: readonly A[]): void
+    update(value: A): boolean
+    insert(value: A, position?: Position<Key>): Readable<A>
+    remove(key: Key): boolean
+    move(key: Key, position?: Position<Key>): boolean
+  }
+
+  export function make<A, Key>(options: {
+    readonly key: (value: A) => Key
+    readonly equivalent?: (left: A, right: A) => boolean
+    readonly metrics?: Metrics
+  }): Keyed<A, Key> {
+    const slots = State.make<readonly Writable<A>[]>([])
+    const byKey = new Map<Key, Writable<A>>()
+    const equivalent = options.equivalent ?? Object.is
+    const values = Computed.make<readonly A[]>((previous) => {
+      const next = slots().map((slot) => slot())
+      return same(previous, next) ? previous! : next
+    })
+
+    return {
+      slots,
+      values,
+      has: (key) => byKey.has(key),
+      get: (key) => byKey.get(key),
+      set(next) {
+        const keys = next.map(options.key)
+        const retained = new Set(keys)
+        if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
+
+        Transaction.run(() => {
+          const previous = slots()
+          const reconciled = next.map((value, index) => {
+            const key = keys[index]
+            const slot = byKey.get(key)
+            if (!slot) {
+              const created = State.make(value)
+              byKey.set(key, created)
+              return created
+            }
+            if (!equivalent(slot(), value)) {
+              slot.set(value)
+              if (options.metrics) options.metrics.slotPublications++
+            } else if (options.metrics) {
+              options.metrics.equivalenceSuppressions++
+            }
+            return slot
+          })
+          byKey.forEach((_slot, key) => {
+            if (!retained.has(key)) byKey.delete(key)
+          })
+          if (!same(previous, reconciled)) {
+            slots.set(reconciled)
+            if (options.metrics) options.metrics.structuralPublications++
+          }
+        })
+      },
+      update(value) {
+        const key = options.key(value)
+        const slot = byKey.get(key)
+        if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`)
+        if (equivalent(slot(), value)) {
+          if (options.metrics) options.metrics.equivalenceSuppressions++
+          return false
+        }
+        slot.set(value)
+        if (options.metrics) options.metrics.slotPublications++
+        return true
+      },
+      insert(value, position) {
+        const key = options.key(value)
+        if (byKey.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`)
+        const current = slots()
+        const index = positionIndex(current, position)
+        const slot = State.make(value)
+        Transaction.run(() => {
+          byKey.set(key, slot)
+          slots.set(current.toSpliced(index, 0, slot))
+          if (options.metrics) options.metrics.structuralPublications++
+        })
+        return slot
+      },
+      remove(key) {
+        const slot = byKey.get(key)
+        if (!slot) return false
+        Transaction.run(() => {
+          byKey.delete(key)
+          slots.set(slots().filter((candidate) => candidate !== slot))
+          if (options.metrics) options.metrics.structuralPublications++
+        })
+        return true
+      },
+      move(key, position) {
+        const slot = byKey.get(key)
+        if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`)
+        const current = slots()
+        const from = current.indexOf(slot)
+        const target = positionIndex(current, position)
+        const to = from < target ? target - 1 : target
+        if (from === to) return false
+        slots.set(current.toSpliced(from, 1).toSpliced(to, 0, slot))
+        if (options.metrics) options.metrics.structuralPublications++
+        return true
+      },
+    }
+
+    function positionIndex(current: readonly Writable<A>[], position?: Position<Key>) {
+      if (position === undefined || position === "end") return current.length
+      if ("before" in position) return indexOf(current, position.before)
+      return indexOf(current, position.after) + 1
+    }
+
+    function indexOf(current: readonly Writable<A>[], key: Key) {
+      const target = byKey.get(key)
+      if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`)
+      return current.indexOf(target)
+    }
+  }
+
+  export function metrics(): Metrics {
+    return { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
+  }
+
+  function same<A>(left: readonly A[] | undefined, right: readonly A[]) {
+    return left?.length === right.length && left.every((value, index) => Object.is(value, right[index]))
+  }
+}

+ 59 - 0
packages/quark/src/reactivity.ts

@@ -0,0 +1,59 @@
+import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals"
+
+export interface Readable<A> {
+  (): A
+  subscribe(listener: (value: A) => void): () => void
+}
+
+export interface Writable<A> extends Readable<A> {
+  set(value: A): void
+  update(f: (value: A) => A): void
+}
+
+function subscribe<A>(read: () => A, listener: (value: A) => void) {
+  let initialized = false
+  return effect(() => {
+    const value = read()
+    if (!initialized) {
+      initialized = true
+      return
+    }
+    const active = setActiveSub()
+    try {
+      listener(value)
+    } finally {
+      setActiveSub(active)
+    }
+  })
+}
+
+export namespace State {
+  export function make<A>(initial: A): Writable<A> {
+    const state = signal(initial)
+    const read = (() => state()) as Writable<A>
+    read.set = (value) => state(value)
+    read.update = (f) => state(f(state()))
+    read.subscribe = (listener) => subscribe(read, listener)
+    return read
+  }
+}
+
+export namespace Computed {
+  export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
+    const value = computed(evaluate)
+    const read = (() => value()) as Readable<A>
+    read.subscribe = (listener) => subscribe(read, listener)
+    return read
+  }
+}
+
+export namespace Transaction {
+  export function run<A>(f: () => A): A {
+    startBatch()
+    try {
+      return f()
+    } finally {
+      endBatch()
+    }
+  }
+}

+ 22 - 0
packages/quark/src/solid.ts

@@ -0,0 +1,22 @@
+import { For, from, type Accessor, type JSX } from "solid-js"
+import type { Readable } from "./reactivity"
+
+export function useValue<A>(readable: Readable<A>): Accessor<A> {
+  return from(readable, readable())
+}
+
+export function KeyedFor<A>(props: {
+  readonly each: Accessor<readonly Readable<A>[]>
+  readonly fallback?: JSX.Element
+  readonly children: (value: Accessor<A>, index: Accessor<number>) => JSX.Element
+}) {
+  return For({
+    get each() {
+      return props.each()
+    },
+    get fallback() {
+      return props.fallback
+    },
+    children: (slot, index) => props.children(useValue(slot), index),
+  })
+}

+ 201 - 0
packages/quark/test/keyed.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, it } from "bun:test"
+import { Computed, Keyed } from "../src"
+
+type Item = {
+  readonly id: number
+  readonly label: string
+}
+
+const item = (id: number, label: string): Item => ({ id, label })
+
+describe("Keyed", () => {
+  it("keeps slots stable while publishing value and structural changes separately", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(2, "two")])
+    const [one, two] = keyed.slots()
+    const structure = keyed.slots()
+    const structures: number[][] = []
+    const values: Item[][] = []
+    const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id)))
+    const disposeValues = keyed.values.subscribe((next) => values.push([...next]))
+
+    keyed.set([item(1, "ONE"), item(2, "TWO")])
+
+    expect(keyed.slots()).toBe(structure)
+    expect(keyed.slots()).toEqual([one, two])
+    expect(values).toEqual([[item(1, "ONE"), item(2, "TWO")]])
+    expect(structures).toEqual([])
+
+    keyed.set([item(2, "TWO"), item(1, "ONE")])
+
+    expect(keyed.slots()).toEqual([two, one])
+    expect(structures).toEqual([[2, 1]])
+    disposeSlots()
+    disposeValues()
+  })
+
+  it("uses custom equivalence to cut off slot and aggregate updates", () => {
+    const keyed = Keyed.make<Item, number>({
+      key: (value) => value.id,
+      equivalent: (left, right) => left.label.toLowerCase() === right.label.toLowerCase(),
+    })
+    const original = item(1, "one")
+    keyed.set([original])
+    const slot = keyed.slots()[0]
+    const aggregate = keyed.values()
+
+    keyed.set([item(1, "ONE")])
+
+    expect(slot()).toBe(original)
+    expect(keyed.values()).toBe(aggregate)
+  })
+
+  it("creates a fresh slot after removal and reinsertion", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(2, "two")])
+    const [removed, retained] = keyed.slots()
+
+    keyed.set([item(2, "two")])
+    keyed.set([item(1, "new"), item(2, "TWO")])
+
+    expect(keyed.slots()[0]).not.toBe(removed)
+    expect(keyed.slots()[1]).toBe(retained)
+    expect(retained()).toEqual(item(2, "TWO"))
+  })
+
+  it("rejects duplicate keys without partially updating", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(2, "two")])
+    const slots = keyed.slots()
+    const values = keyed.values()
+
+    expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys")
+    expect(keyed.slots()).toBe(slots)
+    expect(keyed.values()).toBe(values)
+  })
+
+  it("uses SameValueZero key equality", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(-0, "zero"), item(Number.NaN, "nan")])
+    const [zero, nan] = keyed.slots()
+
+    keyed.set([item(0, "ZERO"), item(Number.NaN, "NAN")])
+
+    expect(keyed.slots()).toEqual([zero, nan])
+    expect(() => keyed.set([item(0, "zero"), item(-0, "duplicate")])).toThrow("Keyed values must have unique keys")
+    expect(() => keyed.set([item(Number.NaN, "nan"), item(Number.NaN, "duplicate")])).toThrow(
+      "Keyed values must have unique keys",
+    )
+  })
+
+  it("publishes one settled aggregate when values and structure change together", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(2, "two")])
+    const observations: string[] = []
+    const summary = Computed.make(() => {
+      const slots = keyed
+        .slots()
+        .map((slot) => `${slot().id}:${slot().label}`)
+        .join(",")
+      const values = keyed
+        .values()
+        .map((value) => `${value.id}:${value.label}`)
+        .join(",")
+      return `${slots}|${values}`
+    })
+    const dispose = summary.subscribe((value) => observations.push(value))
+
+    keyed.set([item(2, "TWO"), item(3, "three")])
+
+    expect(observations).toEqual(["2:TWO,3:three|2:TWO,3:three"])
+    dispose()
+  })
+
+  it("updates one existing slot without publishing structure", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(2, "two")])
+    const structure = keyed.slots()
+    const two = keyed.slots()[1]
+    const structures: Array<readonly unknown[]> = []
+    const dispose = keyed.slots.subscribe((slots) => structures.push(slots))
+
+    const updated = item(2, "TWO")
+    expect(keyed.update(updated)).toBe(true)
+    expect(keyed.update(updated)).toBe(false)
+
+    expect(keyed.slots()).toBe(structure)
+    expect(keyed.slots()[1]).toBe(two)
+    expect(two()).toEqual(item(2, "TWO"))
+    expect(structures).toEqual([])
+    expect(() => keyed.update(item(3, "three"))).toThrow("Keyed value does not exist: 3")
+    dispose()
+  })
+
+  it("checks key membership without reading the aggregate", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one")])
+
+    expect(keyed.has(1)).toBe(true)
+    expect(keyed.has(2)).toBe(false)
+    expect(keyed.get(1)).toBe(keyed.slots()[0])
+    expect(keyed.get(2)).toBeUndefined()
+    keyed.remove(1)
+    expect(keyed.has(1)).toBe(false)
+    expect(keyed.get(1)).toBeUndefined()
+  })
+
+  it("inserts, removes, and moves stable slots", () => {
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
+    keyed.set([item(1, "one"), item(3, "three")])
+    const one = keyed.slots()[0]
+    const three = keyed.slots()[1]
+
+    const two = keyed.insert(item(2, "two"), { before: 3 })
+    expect(keyed.slots()).toEqual([one, two, three])
+    const four = keyed.insert(item(4, "four"), { after: 3 })
+    expect(keyed.slots()).toEqual([one, two, three, four])
+    expect(keyed.move(3, { before: 1 })).toBe(true)
+    expect(keyed.slots()).toEqual([three, one, two, four])
+    expect(keyed.move(3, { before: 1 })).toBe(false)
+    expect(keyed.move(3, { after: 2 })).toBe(true)
+    expect(keyed.slots()).toEqual([one, two, three, four])
+    expect(keyed.move(3, "end")).toBe(true)
+    expect(keyed.slots()).toEqual([one, two, four, three])
+    expect(keyed.remove(2)).toBe(true)
+    expect(keyed.remove(2)).toBe(false)
+    expect(keyed.slots()).toEqual([one, four, three])
+  })
+
+  it("counts publications and equivalence suppressions when instrumented", () => {
+    const metrics = Keyed.metrics()
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id, metrics })
+
+    keyed.set([item(1, "one")])
+    keyed.update(item(1, "ONE"))
+    const current = keyed.slots()[0]()
+    keyed.update(current)
+    keyed.insert(item(2, "two"))
+    keyed.move(2, { before: 1 })
+    keyed.remove(2)
+
+    expect(metrics).toEqual({
+      slotPublications: 1,
+      structuralPublications: 4,
+      equivalenceSuppressions: 1,
+    })
+  })
+
+  it("publishes nothing for a full unchanged rebuild", () => {
+    const metrics = Keyed.metrics()
+    const keyed = Keyed.make<Item, number>({ key: (value) => value.id, metrics })
+    const values = [item(1, "one"), item(2, "two")]
+    keyed.set(values)
+    const before = { ...metrics }
+
+    keyed.set(values)
+
+    expect(metrics.slotPublications).toBe(before.slotPublications)
+    expect(metrics.structuralPublications).toBe(before.structuralPublications)
+    expect(metrics.equivalenceSuppressions).toBe(before.equivalenceSuppressions + values.length)
+  })
+})

+ 39 - 0
packages/quark/test/reactivity.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from "bun:test"
+import { createEffect, createRoot } from "solid-js"
+import { State } from "../src"
+import { useValue } from "../src/solid"
+
+describe("Quark", () => {
+  it("does not track state read by subscription listeners", () => {
+    const source = State.make(1)
+    const unrelated = State.make(1)
+    const values: number[] = []
+    const dispose = source.subscribe((value) => {
+      unrelated()
+      values.push(value)
+    })
+
+    source.set(2)
+    unrelated.set(2)
+
+    expect(values).toEqual([2])
+    dispose()
+  })
+
+  it("bridges values into a Solid owner and disposes with it", () => {
+    const source = State.make(1)
+    const values: number[] = []
+    let dispose = () => {}
+    createRoot((rootDispose) => {
+      dispose = rootDispose
+      const value = useValue(source)
+      createEffect(() => values.push(value()))
+    })
+
+    source.set(2)
+    dispose()
+    source.set(3)
+
+    expect(values).toEqual([1, 2])
+  })
+})

+ 1 - 0
packages/tui/package.json

@@ -92,6 +92,7 @@
     "clipboardy": "4.0.0",
     "diff": "catalog:",
     "effect": "catalog:",
+    "effect-quark": "workspace:*",
     "fuzzysort": "catalog:",
     "get-east-asian-width": "catalog:",
     "open": "10.1.2",

+ 5 - 4
packages/tui/src/routes/session/index.tsx

@@ -13,6 +13,7 @@ import {
   Switch,
   useContext,
 } from "solid-js"
+import { KeyedFor } from "effect-quark/solid"
 import path from "node:path"
 import { EOL, tmpdir } from "node:os"
 import { mkdir, writeFile } from "node:fs/promises"
@@ -213,7 +214,7 @@ export function Session() {
   })
   const editor = useEditorContext()
   const rows = createSessionRows(() => route.sessionID)
-  const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
+  const boundaries = createMemo(() => messageBoundaryIDs(rows.slots().map((slot) => slot()), messages()))
   const [navigationMessage, setNavigationMessage] = createSignal<string>()
   const [navigationSlack, setNavigationSlack] = createSignal(0)
 
@@ -928,15 +929,15 @@ export function Session() {
               flexGrow={1}
               scrollAcceleration={scrollAcceleration()}
             >
-              <For each={rows}>
+              <KeyedFor each={rows.slots}>
                 {(row, index) => (
                   <SessionRowView
-                    row={row}
+                    row={row()}
                     message={(messageID) => data.session.message.get(route.sessionID, messageID)}
                     boundaryID={boundaries()[index()]}
                   />
                 )}
-              </For>
+              </KeyedFor>
               <BackgroundToolHint messages={messages()} />
               <Show when={session()?.revert?.messageID}>
                 <RevertMessage

+ 193 - 81
packages/tui/src/routes/session/rows.ts

@@ -1,39 +1,73 @@
 import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
-import { createEffect, on, onCleanup, type Accessor } from "solid-js"
-import { createStore, produce, reconcile } from "solid-js/store"
+import { Keyed, Transaction } from "effect-quark"
+import { useValue } from "effect-quark/solid"
+import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js"
 import { useData } from "../../context/data"
 import { useClient } from "../../context/client"
 
 export type PartRef = {
-  messageID: string
-  partID: string
+  readonly messageID: string
+  readonly partID: string
 }
 
-export type SessionRow =
+export type SessionRow = { readonly id: string } & (
   | { type: "message"; messageID: string }
   | { type: "compaction-queued"; inputID: string }
   | { type: "part"; ref: PartRef }
   | {
       type: "group"
       kind: "reasoning"
+      origin: PartRef
       refs: PartRef[]
       completed: boolean
     }
   | {
       type: "group"
       kind: "exploration"
+      origin: PartRef
       refs: PartRef[]
       pending: PartRef[]
       completed: boolean
     }
   | { type: "assistant-footer"; messageID: string }
+)
 
-export function createSessionRows(sessionID: Accessor<string>) {
+export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
   const data = useData()
   const client = useClient()
-  const [rows, setRows] = createStore<SessionRow[]>([])
+  const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1"
+  const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined)
+  const state = Keyed.make({ key: rowKey, equivalent: sameRow, metrics })
+  const seenParts = new Set<string>()
+  const rows = {
+    slots: useValue(state.slots),
+    values: state.values,
+  }
   const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
 
+  const setRows = (value: SessionRow[]) => {
+    batch(() => {
+      state.set(value)
+      seenParts.clear()
+      value.forEach((row) => {
+        if (row.type === "part") {
+          seenParts.add(row.id)
+          return
+        }
+        if (row.type !== "group") return
+        row.refs.forEach((ref) => seenParts.add(partRowID(ref)))
+        if (row.kind === "exploration") row.pending.forEach((ref) => seenParts.add(partRowID(ref)))
+      })
+    })
+  }
+  const mutate = (f: () => void) => batch(() => Transaction.run(f))
+  const insert = (current: readonly SessionRow[], index: number, row: SessionRow) =>
+    state.insert(row, index === current.length ? "end" : { before: current[index].id })
+  const complete = (current: readonly SessionRow[], index: number) => {
+    const previous = current[index - 1]
+    if (previous?.type === "group" && !previous.completed) state.update({ ...previous, completed: true })
+  }
+
   function reduce() {
     const messages = data.session.message.list(sessionID())
     const inputs = new Set(data.session.input.list(sessionID()))
@@ -47,7 +81,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
       ...data.session.pending
         .list(sessionID())
         .filter((item) => item.type === "compaction")
-        .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
+        .map((item) => compactionQueuedRow(item.id)),
     )
     return rows
   }
@@ -62,22 +96,31 @@ export function createSessionRows(sessionID: Accessor<string>) {
 
   createEffect(() => {
     const pending = pendingPermissions()
-    setRows(
-      produce((draft) => {
-        partitionPending(draft, pending)
-      }),
-    )
+    mutate(() => {
+      state.values().forEach((row) => {
+        if (row.type !== "group" || row.kind !== "exploration") return
+        const changed =
+          row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID))
+        if (!changed) return
+        const refs = [...row.refs, ...row.pending]
+        state.update({
+          ...row,
+          refs: refs.filter((ref) => !pending.has(ref.partID)),
+          pending: refs.filter((ref) => pending.has(ref.partID)),
+        })
+      })
+    })
   })
 
   createEffect(
     on([sessionID, () => client.connection.status()], ([id, status]) => {
       if (status !== "connected") return
-      setRows(reconcile(reduce()))
+      setRows(reduce())
       void data.session.pending.sync(id).catch(() => undefined)
       void data.session.message.sync(id).then(
         () => {
           if (sessionID() !== id) return
-          setRows(reconcile(reduce()))
+          setRows(reduce())
         },
         () => undefined,
       )
@@ -87,7 +130,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
   // Re-reduce when the revert boundary changes (stage/clear/commit).
   createEffect(
     on(revertBoundary, () => {
-      setRows(reconcile(reduce()))
+      setRows(reduce())
     }),
   )
 
@@ -98,7 +141,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
           .list(sessionID())
           .filter((item) => item.type === "compaction")
           .map((item) => item.id),
-      () => setRows(reconcile(reduce())),
+      () => setRows(reduce()),
     ),
   )
 
@@ -123,48 +166,57 @@ export function createSessionRows(sessionID: Accessor<string>) {
                 ]
               : [],
         ),
-      () => setRows(reconcile(reduce())),
+      () => setRows(reduce()),
     ),
   )
 
   const appendMessage = (messageID: string) =>
-    setRows(
-      produce((draft) => {
-        if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
-        const pending = isPending(messageID)
-        const message = data.session.message.get(sessionID(), messageID)
-        const index =
-          message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
-        if (!pending) completePrevious(draft, index)
-        draft.splice(index, 0, { type: "message", messageID })
-      }),
-    )
+    mutate(() => {
+      if (state.has(messageRowID(messageID))) return
+      const current = state.values()
+      const pending = isPending(messageID)
+      const message = data.session.message.get(sessionID(), messageID)
+      const index =
+        message?.type === "compaction" && pending
+          ? queuedStart(current)
+          : pending
+            ? current.length
+            : queuedStart(current)
+      if (!pending) complete(current, index)
+      insert(current, index, messageRow(messageID))
+    })
 
   const appendPart = (ref: PartRef, part: AppendPart) =>
-    setRows(
-      produce((draft) => {
-        if (hasPart(draft, ref)) return
-        append(draft, ref, part, queuedStart(draft))
-      }),
-    )
+    mutate(() => {
+      const id = partRowID(ref)
+      if (seenParts.has(id)) return
+      const current = state.values()
+      const index = queuedStart(current)
+      const previous = current[index - 1]
+      const decision = appendDecision(previous, ref, part)
+      if (decision.type === "join") {
+        state.update({ ...decision.group, refs: [...decision.group.refs, ref] })
+        seenParts.add(id)
+        return
+      }
+      complete(current, index)
+      insert(current, index, decision.row)
+      seenParts.add(id)
+    })
 
   const appendFooter = (messageID: string) =>
-    setRows(
-      produce((draft) => {
-        if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
-        const index = queuedStart(draft)
-        completePrevious(draft, index)
-        draft.splice(index, 0, { type: "assistant-footer", messageID })
-      }),
-    )
+    mutate(() => {
+      if (state.has(footerRowID(messageID))) return
+      const current = state.values()
+      const index = queuedStart(current)
+      complete(current, index)
+      insert(current, index, footerRow(messageID))
+    })
 
   const removeFooter = (messageID: string) =>
-    setRows(
-      produce((draft) => {
-        const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
-        if (index !== -1) draft.splice(index, 1)
-      }),
-    )
+    mutate(() => {
+      state.remove(footerRowID(messageID))
+    })
 
   const isPending = (messageID: string) => {
     const message = data.session.message.get(sessionID(), messageID)
@@ -172,7 +224,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
     return message?.type === "compaction" && message.status === "running"
   }
 
-  const queuedStart = (rows: SessionRow[]) => {
+  const queuedStart = (rows: readonly SessionRow[]) => {
     const index = rows.findIndex(
       (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
     )
@@ -252,6 +304,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
     }),
   ]
   onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
+  onCleanup(() => {
+    if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`)
+  })
 
   return rows
 }
@@ -268,7 +323,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
     if (message.type !== "assistant") {
       if (message.type === "synthetic" && !message.description?.trim()) return rows
       if (!pending.has(message.id)) completePrevious(rows)
-      rows.push({ type: "message", messageID: message.id })
+      rows.push(messageRow(message.id))
       return rows
     }
     const ordinals = { text: 0, reasoning: 0 }
@@ -279,13 +334,20 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
     })
     if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
       completePrevious(rows)
-      rows.push({ type: "assistant-footer", messageID: message.id })
+      rows.push(footerRow(message.id))
     }
     return rows
   }, [])
 }
 
-export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
+type BoundaryRow =
+  | Pick<Extract<SessionRow, { type: "message" }>, "type" | "messageID">
+  | Pick<Extract<SessionRow, { type: "compaction-queued" }>, "type">
+  | Pick<Extract<SessionRow, { type: "part" }>, "type" | "ref">
+  | Pick<Extract<SessionRow, { type: "group" }>, "type" | "origin">
+  | Pick<Extract<SessionRow, { type: "assistant-footer" }>, "type" | "messageID">
+
+export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) {
   const byID = new Map(messages.map((message) => [message.id, message]))
   const seen = new Set<string>()
   return rows.map((row) => {
@@ -296,7 +358,7 @@ export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageI
   })
 }
 
-function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
+function rowBoundaryMessageID(row: BoundaryRow, messages: Map<string, SessionMessageInfo>) {
   if (row.type === "message") {
     const message = messages.get(row.messageID)
     if (message?.type === "user" && message.text.trim()) return message.id
@@ -306,7 +368,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
     row.type === "part"
       ? row.ref.messageID
       : row.type === "group"
-        ? row.refs[0]?.messageID
+        ? row.origin.messageID
         : row.type === "assistant-footer"
           ? row.messageID
           : undefined
@@ -327,28 +389,25 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
 type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
 
 function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
-  if (part.type === "reasoning") {
-    const previous = rows[index - 1]
-    if (previous?.type === "group" && previous.kind === "reasoning") {
-      previous.refs.push(ref)
-      return
-    }
-    completePrevious(rows, index)
-    rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
-    return
-  }
-  if (part.type === "tool" && exploration(part.name)) {
-    const previous = rows[index - 1]
-    if (previous?.type === "group" && previous.kind === "exploration") {
-      previous.refs.push(ref)
-      return
-    }
-    completePrevious(rows, index)
-    rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
+  const previous = rows[index - 1]
+  const decision = appendDecision(previous, ref, part)
+  if (decision.type === "join") {
+    decision.group.refs.push(ref)
     return
   }
   completePrevious(rows, index)
-  rows.splice(index, 0, { type: "part", ref })
+  rows.splice(index, 0, decision.row)
+}
+
+function appendDecision(previous: SessionRow | undefined, ref: PartRef, part: AppendPart) {
+  const kind = groupKind(part)
+  if (kind && previous?.type === "group" && previous.kind === kind) return { type: "join" as const, group: previous }
+  return { type: "insert" as const, row: kind ? groupRow(kind, ref) : partRow(ref) }
+}
+
+function groupKind(part: AppendPart) {
+  if (part.type === "reasoning") return "reasoning" as const
+  if (part.type === "tool" && exploration(part.name)) return "exploration" as const
 }
 
 function completePrevious(rows: SessionRow[], index = rows.length) {
@@ -369,11 +428,64 @@ function exploration(name: string) {
   return ["read", "glob", "grep"].includes(name.toLowerCase())
 }
 
-function hasPart(rows: SessionRow[], ref: PartRef) {
-  return rows.some((row) => {
-    if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
-    if (row.type !== "group") return false
-    const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
-    return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
-  })
+function messageRow(messageID: string): SessionRow {
+  return { id: messageRowID(messageID), type: "message", messageID }
+}
+
+function messageRowID(messageID: string) {
+  return `m${segment(messageID)}`
+}
+
+function compactionQueuedRow(inputID: string): SessionRow {
+  return { id: `c${segment(inputID)}`, type: "compaction-queued", inputID }
+}
+
+function partRow(ref: PartRef): SessionRow {
+  return { id: partRowID(ref), type: "part", ref }
+}
+
+function partRowID(ref: PartRef) {
+  return `p${segment(ref.messageID)}${segment(ref.partID)}`
+}
+
+function groupRow(kind: "reasoning" | "exploration", ref: PartRef): SessionRow {
+  const id = `g${kind === "reasoning" ? "r" : "e"}${segment(ref.messageID)}${segment(ref.partID)}`
+  if (kind === "reasoning") return { id, type: "group", kind, origin: ref, refs: [ref], completed: false }
+  return { id, type: "group", kind, origin: ref, refs: [ref], pending: [], completed: false }
+}
+
+function footerRow(messageID: string): SessionRow {
+  return { id: footerRowID(messageID), type: "assistant-footer", messageID }
+}
+
+function footerRowID(messageID: string) {
+  return `f${segment(messageID)}`
+}
+
+function segment(value: string) {
+  return `${value.length}:${value}`
+}
+
+function rowKey(row: SessionRow) {
+  return row.id
+}
+
+function sameRow(left: SessionRow, right: SessionRow) {
+  if (left.type !== right.type) return false
+  if (left.type === "message" && right.type === "message") return left.messageID === right.messageID
+  if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID
+  if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref)
+  if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID
+  if (left.type !== "group" || right.type !== "group") return false
+  if (left.kind !== right.kind || left.completed !== right.completed || !sameRefs(left.refs, right.refs)) return false
+  if (left.kind === "reasoning" || right.kind === "reasoning") return true
+  return sameRefs(left.pending, right.pending)
+}
+
+function sameRefs(left: PartRef[], right: PartRef[]) {
+  return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index]))
+}
+
+function sameRef(left: PartRef, right: PartRef) {
+  return left.messageID === right.messageID && left.partID === right.partID
 }

+ 194 - 26
packages/tui/test/cli/tui/data.test.tsx

@@ -28,6 +28,19 @@ async function wait(fn: () => boolean, timeout = 2000) {
   }
 }
 
+function readRows(rows: ReturnType<typeof createSessionRows>) {
+  return rows.values()
+}
+
+function withoutRowID(row: SessionRow) {
+  const { id: _id, ...value } = row
+  if (value.type === "group") {
+    const { origin: _origin, ...group } = value
+    return group
+  }
+  return value
+}
+
 function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
   events.emit({ ...event, location: { directory } })
 }
@@ -717,10 +730,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
   }, events)
   let rows!: ReturnType<typeof createSessionRows>
   let client!: ReturnType<typeof useClient>
+  let structuralUpdates = 0
+  const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
 
   function Probe() {
     client = useClient()
-    rows = createSessionRows(() => sessionID)
+    rows = createSessionRows(() => sessionID, { metrics })
+    createEffect(() => {
+      rows.slots()
+      structuralUpdates++
+    })
     return <box />
   }
 
@@ -762,31 +781,81 @@ test("completes exploration when a queued prompt is promoted", async () => {
         name: "read",
       },
     })
-    await wait(() => rows.some((row) => row.type === "group" && !row.completed))
+    await wait(() => readRows(rows).some((row) => row.type === "group" && !row.completed))
+    expect(metrics.slotPublications).toBe(0)
+    expect(metrics.structuralPublications).toBe(1)
+
+    emitEvent(events, {
+      id: "evt_tool_started_2",
+      created: 2,
+      type: "session.tool.input.started",
+      durable: durable(sessionID, 2),
+      data: {
+        sessionID,
+        assistantMessageID: "message-assistant",
+        callID: "call-read-2",
+        name: "read",
+      },
+    })
+    await wait(() => {
+      const group = readRows(rows).find((row) => row.type === "group")
+      return group?.refs.length === 2
+    })
+    expect(metrics.slotPublications).toBe(1)
+    expect(metrics.structuralPublications).toBe(1)
+    const groupSlot = rows.slots().find((slot) => slot().type === "group")
+    expect(groupSlot).toBeDefined()
+    const beforePermission = structuralUpdates
+
+    emitEvent(events, {
+      id: "evt_permission_asked",
+      created: 2,
+      type: "permission.v2.asked",
+      data: {
+        id: "permission-read",
+        sessionID,
+        action: "read",
+        resources: ["src/example.ts"],
+        source: { type: "tool", messageID: "message-assistant", callID: "call-read" },
+      },
+    })
+    await wait(() => {
+      const group = readRows(rows).find((row) => row.type === "group" && row.kind === "exploration")
+      return group?.pending[0]?.partID === "call-read"
+    })
+    expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
+    expect(structuralUpdates).toBe(beforePermission)
+    expect(metrics.slotPublications).toBe(2)
+    expect(metrics.structuralPublications).toBe(1)
 
     emitEvent(events, {
       id: "evt_prompt_admitted",
       created: 3,
       type: "session.input.admitted",
-      durable: durable(sessionID, 2),
+      durable: durable(sessionID, 3),
       data: {
         sessionID,
         inputID: "message-user",
         input: { type: "user", data: { text: "Continue" }, delivery: "steer" },
       },
     })
-    await wait(() => rows.at(-1)?.type === "message")
-    expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
+    await wait(() => readRows(rows).at(-1)?.type === "message")
+    expect(readRows(rows).find((row) => row.type === "group")?.completed).toBe(false)
+    expect(metrics.slotPublications).toBe(2)
+    expect(metrics.structuralPublications).toBe(2)
 
     emitEvent(events, {
       id: "evt_prompt_promoted",
       created: 4,
       type: "session.input.promoted",
-      durable: durable(sessionID, 3),
+      durable: durable(sessionID, 4),
       data: { sessionID, inputID: "message-user" },
     })
-    await wait(() => rows.find((row) => row.type === "group")?.completed === true)
-    expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
+    await wait(() => readRows(rows).find((row) => row.type === "group")?.completed === true)
+    expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
+    expect(withoutRowID(readRows(rows).at(-1)!)).toEqual({ type: "message", messageID: "message-user" })
+    expect(metrics.slotPublications).toBe(3)
+    expect(metrics.structuralPublications).toBe(2)
   } finally {
     app.renderer.destroy()
   }
@@ -834,8 +903,90 @@ test("classifies live tool rows independently of their call ID", async () => {
       },
     })
 
-    await wait(() => rows.length > 0)
-    expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
+    await wait(() => readRows(rows).length > 0)
+    expect(readRows(rows).map(withoutRowID)).toEqual([
+      { type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } },
+    ])
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
+test("does not publish timeline rows for duplicate streaming deltas", async () => {
+  const events = createEventStream()
+  const sessionID = "session-stream-metrics"
+  const calls = createFetch((url) => {
+    if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
+  }, events)
+  const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
+  let data!: ReturnType<typeof useData>
+  let rows!: ReturnType<typeof createSessionRows>
+  let client!: ReturnType<typeof useClient>
+
+  function Probe() {
+    data = useData()
+    client = useClient()
+    rows = createSessionRows(() => sessionID, { metrics })
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts>
+      <ClientProvider api={createApi(calls.fetch)}>
+        <ProjectProvider>
+          <DataProvider>
+            <Probe />
+          </DataProvider>
+        </ProjectProvider>
+      </ClientProvider>
+    </TestTuiContexts>
+  ))
+
+  try {
+    await wait(() => client.connection.status() === "connected")
+    emitEvent(events, {
+      id: "evt_stream_step",
+      created: 1,
+      type: "session.step.started",
+      durable: durable(sessionID),
+      data: {
+        sessionID,
+        assistantMessageID: "message-assistant",
+        agent: "build",
+        model: { id: "model", providerID: "provider" },
+      },
+    })
+    emitEvent(events, {
+      id: "evt_stream_started",
+      created: 2,
+      type: "session.text.started",
+      durable: durable(sessionID, 1),
+      data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
+    })
+    emitEvent(events, {
+      id: "evt_stream_delta_1",
+      created: 3,
+      type: "session.text.delta",
+      data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
+    })
+    await wait(() => readRows(rows).some((row) => row.type === "part"))
+    const afterFirst = { ...metrics }
+
+    emitEvent(events, {
+      id: "evt_stream_delta_2",
+      created: 4,
+      type: "session.text.delta",
+      data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
+    })
+    await wait(() => {
+      const message = data.session.message.get(sessionID, "message-assistant")
+      return (
+        message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo")
+      )
+    })
+
+    expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 })
+    expect(metrics).toEqual(afterFirst)
   } finally {
     app.renderer.destroy()
   }
@@ -987,13 +1138,15 @@ test("tracks session status from active sessions and execution events", async ()
       })
   }, events)
   let data!: ReturnType<typeof useData>
-  let rows!: SessionRow[]
-  let manualRows!: SessionRow[]
+  let rows!: ReturnType<typeof createSessionRows>
+  let manualRows!: ReturnType<typeof createSessionRows>
+  let liveRows!: ReturnType<typeof createSessionRows>
 
   function Probe() {
     data = useData()
     rows = createSessionRows(() => "session-retry")
     manualRows = createSessionRows(() => "session-manual")
+    liveRows = createSessionRows(() => "session-live")
     return <box />
   }
 
@@ -1189,7 +1342,7 @@ test("tracks session status from active sessions and execution events", async ()
       const assistant = data.session.message.get("session-retry", "message-retry")
       return assistant?.type === "assistant" && assistant.retry?.attempt === 2
     })
-    await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
+    await wait(() => readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
     emitEvent(events, {
       id: "evt_retry_next_step",
       created: 2_000,
@@ -1206,7 +1359,9 @@ test("tracks session status from active sessions and execution events", async ()
       const assistant = data.session.message.get("session-retry", "message-retry")
       return assistant?.type === "assistant" && assistant.retry === undefined
     })
-    await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
+    await wait(
+      () => !readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"),
+    )
     expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
     emitEvent(events, {
       id: "evt_retry_scheduled_again",
@@ -1261,7 +1416,9 @@ test("tracks session status from active sessions and execution events", async ()
       return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
     })
     expect(data.session.pending.list("session-manual")).toEqual([])
-    const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
+    const compactionRow = readRows(manualRows).find(
+      (row) => row.type === "message" && row.messageID === "message-compaction",
+    )
     emitEvent(events, {
       id: "evt_manual_compaction_ended",
       created: 3,
@@ -1273,10 +1430,12 @@ test("tracks session status from active sessions and execution events", async ()
       const message = data.session.message.get("session-manual", "message-compaction")
       return message?.type === "compaction" && message.status === "completed"
     })
-    expect(manualRows.filter((row) => row.type === "message")).toEqual([
-      { type: "message", messageID: "message-compaction" },
-    ])
-    expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
+    expect(
+      readRows(manualRows)
+        .filter((row) => row.type === "message")
+        .map(withoutRowID),
+    ).toEqual([{ type: "message", messageID: "message-compaction" }])
+    expect(readRows(manualRows).find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
       compactionRow,
     )
 
@@ -1303,7 +1462,10 @@ test("tracks session status from active sessions and execution events", async ()
       const message = data.session.message.get("session-live", "msg_compaction_started")
       return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
     })
-    const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
+    const autoCompactionRow = readRows(liveRows).find(
+      (row) => row.type === "message" && row.messageID === "msg_compaction_started",
+    )
+    expect(autoCompactionRow).toBeDefined()
 
     emitEvent(events, {
       id: "evt_compaction_ended",
@@ -1321,10 +1483,12 @@ test("tracks session status from active sessions and execution events", async ()
       status: "completed",
       summary: "Live summary",
     })
-    expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
+    expect(readRows(liveRows).find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
       autoCompactionRow,
     )
-    expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
+    expect(
+      readRows(liveRows).some((row) => row.type === "message" && row.messageID === "msg_compaction_ended"),
+    ).toBeFalse()
   } finally {
     app.renderer.destroy()
   }
@@ -1383,8 +1547,12 @@ test("restores queued compaction from durable pending input", async () => {
       "message-compaction-queued",
       "message-compaction-later",
     ])
-    await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
-    expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
+    await wait(() => readRows(rows).filter((row) => row.type === "compaction-queued").length === 2)
+    expect(
+      readRows(rows)
+        .filter((row) => row.type === "compaction-queued")
+        .map(withoutRowID),
+    ).toEqual([
       { type: "compaction-queued", inputID: "message-compaction-queued" },
       { type: "compaction-queued", inputID: "message-compaction-later" },
     ])
@@ -1401,8 +1569,8 @@ test("restores queued compaction from durable pending input", async () => {
         text: "Active output",
       },
     })
-    await wait(() => rows.some((row) => row.type === "part"))
-    expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
+    await wait(() => readRows(rows).some((row) => row.type === "part"))
+    expect(readRows(rows).map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
 
     emitEvent(events, {
       id: "evt_compaction_started",

+ 34 - 11
packages/tui/test/cli/tui/session-rows.test.ts

@@ -1,6 +1,13 @@
 import { expect, test } from "bun:test"
 import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
-import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
+import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows"
+
+const withoutIDs = (rows: ReturnType<typeof reduceSessionRows>) =>
+  rows.map(({ id: _id, ...row }) => {
+    if (row.type !== "group") return row
+    const { origin: _origin, ...group } = row
+    return group
+  })
 
 test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
   const messages: SessionMessageInfo[] = [
@@ -16,6 +23,22 @@ test("assigns assistant boundaries to the first rendered row instead of the firs
   expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
 })
 
+test("keeps a group boundary at its immutable origin while visible refs repartition", () => {
+  const messages = [assistant("assistant-1", []), assistant("assistant-2", [])]
+  const origin = { messageID: "assistant-1", partID: "read-1" }
+  const group: SessionRow = {
+    id: "group",
+    type: "group",
+    kind: "exploration",
+    origin,
+    refs: [{ messageID: "assistant-2", partID: "grep-1" }],
+    pending: [origin],
+    completed: false,
+  }
+
+  expect(messageBoundaryIDs([group], messages)).toEqual(["assistant-1"])
+})
+
 test("groups exploration parts across assistant messages until a delimiter", () => {
   const messages: SessionMessageInfo[] = [
     { type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
@@ -30,7 +53,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
     ]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     { type: "message", messageID: "user-1" },
     { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
     {
@@ -57,7 +80,7 @@ test("keeps non-exploration tools as individual part rows", () => {
     ]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "exploration",
@@ -86,7 +109,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
     ]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
     {
       type: "group",
@@ -114,7 +137,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
     ]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "reasoning",
@@ -146,7 +169,7 @@ test("groups across empty assistant reasoning parts", () => {
     ]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "reasoning",
@@ -177,7 +200,7 @@ test("completes exploration groups when another row follows", () => {
     finished,
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "exploration",
@@ -209,7 +232,7 @@ test("hides synthetic messages without descriptions", () => {
     assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "exploration",
@@ -236,7 +259,7 @@ test("renders synthetic messages with descriptions", () => {
     assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
   ]
 
-  expect(reduceSessionRows(messages)).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages))).toEqual([
     {
       type: "group",
       kind: "exploration",
@@ -263,7 +286,7 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
     error: { type: "provider.transport", message: "Disconnected" },
   }
 
-  expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
+  expect(withoutIDs(reduceSessionRows([message]))).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
 })
 
 test("places a running compaction barrier before every queued user message", () => {
@@ -287,7 +310,7 @@ test("places a running compaction barrier before every queued user message", ()
     queued("user-after", "After", 3),
   ]
 
-  expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
+  expect(withoutIDs(reduceSessionRows(messages, new Set(["user-before", "user-after"])))).toEqual([
     { type: "message", messageID: "compaction" },
     { type: "message", messageID: "user-before" },
     { type: "message", messageID: "user-after" },

+ 76 - 0
script/quark-group-drive.ts

@@ -0,0 +1,76 @@
+import { Effect, Stream } from "effect"
+import { defineScript, Llm } from "opencode-drive"
+
+const marker = "QUARK_GROUP_COMPLETE"
+
+export default defineScript({
+  project: {
+    git: true,
+    files: {
+      "src/one.ts": "export const one = 1\n",
+      "src/two.ts": "export const two = 2\n",
+    },
+  },
+  config: {
+    permissions: [
+      { action: "*", resource: "*", effect: "ask" },
+      { action: "read", resource: "*one.ts", effect: "allow" },
+    ],
+  },
+  tui: { viewport: { cols: 120, rows: 36 }, recording: true },
+  run: ({ llm, ui }) =>
+    Effect.gen(function* () {
+      yield* ui.waitFor((state) => state.focused.editor)
+      let attempt = 0
+      yield* llm.serve(() => {
+        const current = attempt++
+        if (current === 0)
+          return Stream.make(
+            Llm.toolCall({
+              index: 0,
+              id: "call_read_one",
+              name: "read",
+              input: { path: "src/one.ts" },
+            }),
+            Llm.finish("tool-calls"),
+          )
+        if (current === 1)
+          return Stream.make(
+            Llm.toolCall({
+              index: 0,
+              id: "call_read_two",
+              name: "read",
+              input: { path: "src/two.ts" },
+            }),
+            Llm.finish("tool-calls"),
+          )
+        return Stream.make(Llm.text(marker), Llm.finish("stop"))
+      })
+      yield* ui.submit("Inspect both source modules, then finish.")
+      yield* ui.waitFor("Permission required", { timeout: 15_000 })
+      const frame = yield* ui.capture()
+      const row = frame.lines.findIndex((line) => line.spans.some((span) => span.text.includes("Exploring")))
+      if (row === -1) throw new Error("the exploration summary was not visible")
+      const column = 8
+      const candidates = (yield* ui.state()).elements.filter(
+        (element) =>
+          element.x <= column &&
+          element.x + element.width > column &&
+          element.y <= row &&
+          element.y + element.height > row,
+      )
+      if (candidates.length === 0) throw new Error("the exploration summary had no renderable target")
+      const summary = candidates.reduce((smallest, element) =>
+        element.width * element.height < smallest.width * smallest.height ? element : smallest,
+      )
+      yield* ui.click(summary, { x: column - summary.x, y: row - summary.y })
+      yield* ui.waitFor("src/one.ts")
+      const before = yield* ui.screenshot("group-expanded-pending")
+      yield* ui.enter()
+      yield* ui.waitFor(marker, { timeout: 15_000 })
+      yield* ui.waitFor("src/two.ts")
+      const after = yield* ui.screenshot("group-expanded-complete")
+      yield* Effect.sync(() => console.log(`ARTIFACT group_before=${before}`))
+      yield* Effect.sync(() => console.log(`ARTIFACT group_after=${after}`))
+    }),
+})

+ 46 - 0
script/quark-timeline-drive.ts

@@ -0,0 +1,46 @@
+import { Effect } from "effect"
+import { defineScript, Llm } from "opencode-drive"
+
+const marker = "QUARK_TIMELINE_COMPLETE"
+const reasoning = Array.from(
+  { length: 16 },
+  (_, index) => `Reasoning segment ${index + 1} checks streamed timeline updates.`,
+).join(" ")
+const response = `${Array.from(
+  { length: 24 },
+  (_, index) => `Timeline chunk ${index + 1} remains visible and ordered.`,
+).join(" ")} ${marker}`
+
+export default defineScript({
+  project: {
+    git: true,
+    files: {
+      "src/example.ts": "export const timeline = true\n",
+    },
+  },
+  tui: { viewport: { cols: 120, rows: 36 } },
+  run: ({ opencode, llm, ui }) =>
+    Effect.gen(function* () {
+      yield* ui.waitFor((state) => state.focused.editor)
+      yield* llm.title(() => Effect.succeed("Timeline comparison"))
+      const started = Date.now()
+      yield* ui.submit("Explain how this project handles its timeline.")
+      yield* llm.send(
+        Llm.reasoning(reasoning, { delay: 1, chunkSize: 8 }),
+        Llm.text(response, { delay: 1, chunkSize: 8 }),
+      )
+      yield* ui.waitFor(marker, { timeout: 30_000 })
+      const session = (yield* opencode.session.list({})).data[0]
+      if (!session) throw new Error("the Drive session was not projected")
+      const assistant = (yield* opencode.message.list({ sessionID: session.id })).data.findLast(
+        (message) => message.type === "assistant",
+      )
+      if (assistant?.type !== "assistant") throw new Error("the assistant message was not projected")
+      if (assistant.content.find((part) => part.type === "reasoning")?.text !== reasoning)
+        throw new Error("the projected reasoning content did not match")
+      if (assistant.content.find((part) => part.type === "text")?.text !== response)
+        throw new Error("the projected text content did not match")
+      if (assistant.finish !== "stop") throw new Error("the projected assistant message did not finish")
+      yield* Effect.sync(() => console.log(`METRIC tui_timeline_drive_ms=${Date.now() - started}`))
+    }),
+})