feat(tui): add quark timeline experiment

This commit is contained in:
Kit Langton 2026-07-17 16:49:14 -04:00
commit daf8e539bf
22 changed files with 2804 additions and 122 deletions

View file

@ -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=="],

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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)]
}

View file

@ -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()

View file

@ -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()

View file

@ -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:"
}
}

View file

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

143
packages/quark/src/keyed.ts Normal file
View file

@ -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]))
}
}

View file

@ -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()
}
}
}

View file

@ -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),
})
}

View file

@ -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)
})
})

View file

@ -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])
})
})

View file

@ -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",

View file

@ -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

View file

@ -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
}

View file

@ -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",

View file

@ -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" },

View file

@ -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}`))
}),
})

View file

@ -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}`))
}),
})