From 80e8bd4b840b81ff088564f5e11e482d5c72adcf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 16:49:14 -0400 Subject: [PATCH 01/18] feat(tui): add quark timeline experiment --- bun.lock | 13 + docs/design/quark-architecture-review.md | 303 ++++++++++ docs/design/quark-performance-model.md | 550 ++++++++++++++++++ docs/design/quark-review-recommendations.md | 137 +++++ docs/design/quark-tui-timeline.md | 426 ++++++++++++++ packages/quark/bench/harness.ts | 60 ++ packages/quark/bench/keyed.ts | 217 +++++++ packages/quark/bench/row-key.ts | 63 ++ packages/quark/package.json | 20 + packages/quark/src/index.ts | 2 + packages/quark/src/keyed.ts | 143 +++++ packages/quark/src/reactivity.ts | 59 ++ packages/quark/src/solid.ts | 22 + packages/quark/test/keyed.test.ts | 201 +++++++ packages/quark/test/reactivity.test.ts | 39 ++ packages/tui/package.json | 1 + packages/tui/src/routes/session/index.tsx | 9 +- packages/tui/src/routes/session/rows.ts | 274 ++++++--- packages/tui/test/cli/tui/data.test.tsx | 220 ++++++- .../tui/test/cli/tui/session-rows.test.ts | 45 +- script/quark-group-drive.ts | 76 +++ script/quark-timeline-drive.ts | 46 ++ 22 files changed, 2804 insertions(+), 122 deletions(-) create mode 100644 docs/design/quark-architecture-review.md create mode 100644 docs/design/quark-performance-model.md create mode 100644 docs/design/quark-review-recommendations.md create mode 100644 docs/design/quark-tui-timeline.md create mode 100644 packages/quark/bench/harness.ts create mode 100644 packages/quark/bench/keyed.ts create mode 100644 packages/quark/bench/row-key.ts create mode 100644 packages/quark/package.json create mode 100644 packages/quark/src/index.ts create mode 100644 packages/quark/src/keyed.ts create mode 100644 packages/quark/src/reactivity.ts create mode 100644 packages/quark/src/solid.ts create mode 100644 packages/quark/test/keyed.test.ts create mode 100644 packages/quark/test/reactivity.test.ts create mode 100644 script/quark-group-drive.ts create mode 100644 script/quark-timeline-drive.ts diff --git a/bun.lock b/bun.lock index 1d50a2d541bc..9b84a91cb6b9 100644 --- a/bun.lock +++ b/bun.lock @@ -778,6 +778,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", @@ -1035,6 +1043,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", + "effect-quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", @@ -3477,6 +3486,8 @@ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "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=="], @@ -4039,6 +4050,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=="], diff --git a/docs/design/quark-architecture-review.md b/docs/design/quark-architecture-review.md new file mode 100644 index 000000000000..45da7f1c78fc --- /dev/null +++ b/docs/design/quark-architecture-review.md @@ -0,0 +1,303 @@ +# Quark Timeline Architecture — and How It Can Be Faster + +An architecture review of the `opencode-quark-timeline` experiment: what the layers are, why the design is sound, and the *mechanical* reason it can beat the Solid Store timeline — with the actual code. + +## The Stack in One Picture + +Four small layers, each with one job. The reusable core remains about 200 lines. + +```mermaid +flowchart TD + E[Server events] --> D[DataProvider
Solid Store, unchanged] + D --> R[createSessionRows
row reduction policy] + R -->|"set / update / insert / remove"| K[Keyed<SessionRow, string>
packages/quark/src/keyed.ts] + K -->|"slots — structure channel"| FOR["Solid <For>"] + K -->|"slot — per-row value channel"| UV[useValue adapter
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 { + readonly slots: Readable[]> // fires ONLY on insert/remove/reorder + readonly values: Readable // fires on any current value change + has(key: Key): boolean + get(key: Key): Readable | undefined + set(values: readonly A[]): void + update(value: A): boolean + insert(value: A, position?: "end" | { before: Key } | { after: Key }): Readable + 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. 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(options: { + readonly key: (value: A) => Key + readonly equivalent?: (left: A, right: A) => boolean +}): Keyed { + const slots = State.make[]>([]) // structure channel: one signal holding the slot array + const byKey = new Map>() // identity → slot address, maintained by set/insert/remove + const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI) + const values = Computed.make((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(readable: Readable): Accessor { + const [value, setValue] = createSignal(readable()) + onCleanup(readable.subscribe((next) => setValue(() => next))) + return value +} +``` + +### The structural cutoff — why `` never even wakes up + +Because `slots` only fires on structural change, a value-only update leaves the outer array **reference-identical**. The `` 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 — 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 ``, 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" + + {(row, index) => ( + + )} + +``` + +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" + + {(row, index) => ( + + )} + +``` + +### 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; `` 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. diff --git a/docs/design/quark-performance-model.md b/docs/design/quark-performance-model.md new file mode 100644 index 000000000000..84abf5175e1b --- /dev/null +++ b/docs/design/quark-performance-model.md @@ -0,0 +1,550 @@ +# Why Quark Can Be Faster Than Solid Store + +Status: experimental performance model + +## Conclusion + +Quark can be faster than the current Solid Store timeline because it accepts a +stronger contract and uses that contract to do less runtime work. + +The advantage does **not** come from TypeScript types by themselves. TypeScript +types disappear at runtime. The useful information comes from explicit runtime +inputs and API laws: + +- A row key is unique and cannot change during its ownership lifetime. +- Row equivalence is supplied before updates begin. +- A keyed list has one stable slot per key. +- Value changes and structural changes publish through separate channels. +- A transaction publishes only settled graph state. + +Those guarantees remove choices from the hot path. Quark does not need to +rediscover identity, shape, equivalence, index membership, or ownership on +every update. Less discovery means fewer comparisons, proxy traps, +allocations, writes, and downstream invalidations. + +This is a conditional claim, not a claim that Quark is universally faster than +Solid. Solid can be as fast or faster when an application performs a precise +direct store write, when lists are tiny, or when Quark receives no useful +identity information. The relevant comparison is the current OpenCode path: +whole projected records and ordered rows are reconciled into a Solid Store. + +## The Physical Argument Is “Do Less Work” + +Software cannot escape the physical costs of instructions, memory reads, +allocations, pointer writes, cache misses, and notification fan-out. An +abstraction can only become faster by reducing those costs or arranging them +more favorably. + +Quark's proposed advantage has four concrete sources: + +1. **Declare repeated decisions once.** Identity and equivalence become + preconfigured functions rather than per-event discovery. +2. **Replace searches with addresses.** Stable keys resolve to persistent + slots through a `Map`. +3. **Separate unrelated change dimensions.** An item value can change without + publishing a new list structure. +4. **Stop propagation early.** Schema equivalence and slot equivalence suppress + writes before they reach Solid or the terminal renderer. + +These are ordinary computer costs, not framework mythology. If profiling shows +that Quark executes the same work as Solid plus an adapter, the hypothesis is +false. If counters show fewer structural publications, fewer component-owner +reconciliations, and fewer proxy/store operations, the speedup has a mechanical +explanation. + +## Solid Store Must Preserve Generality + +The current timeline asks Solid Store to accept ordinary JavaScript objects and +reconcile new projections into a proxy-backed graph. + +```mermaid +flowchart LR + A[Projected rows] --> B[Solid reconcile] + B --> C[Inspect keys and shape] + C --> D[Proxy-backed Store writes] + D --> E[Dependency invalidation] + E --> F[For reconciliation] + F --> G[SessionRowView] +``` + +Solid's generality is valuable. It can react to arbitrary nested property +access, partial path writes, and plain objects without requiring a schema-owned +model. That generality also means the runtime must maintain proxy metadata and +interpret each reconciliation against the current store graph. + +Solid is not inherently inefficient. A precise call such as +`setStore(index, "value", next)` can avoid whole-list reconciliation. OpenCode's +timeline frequently receives whole projected messages or rebuilt row arrays, +however, so its current implementation uses `produce` and `reconcile` to +recover identity and preserve nested owners. + +## Quark Makes Identity an Input + +`Keyed` receives the identity and equivalence functions when the collection is +created. In the TUI, every row carries a collision-safe primitive `id`. Groups +also retain their immutable origin for message-boundary projection: + +```typescript +type PartRef = { + messageID: string + partID: string +} + +type SessionRow = { readonly id: string } & ( + | { type: "message"; messageID: string } + | { type: "compaction-queued"; inputID: string } + | { type: "part"; ref: PartRef } + | { + type: "group" + kind: "reasoning" + origin: PartRef + refs: PartRef[] + completed: boolean + } + | { + type: "group" + kind: "exploration" + origin: PartRef + refs: PartRef[] + pending: PartRef[] + completed: boolean + } + | { type: "assistant-footer"; messageID: string } +) +``` + +Factories build the ID once with length-prefixed segments. Length prefixes +avoid delimiter collisions without invoking a serializer: + +```typescript +function segment(value: string) { + return `${value.length}:${value}` +} + +function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRow { + const id = `g${kind === "reasoning" ? "r" : "e"}${segment(origin.messageID)}${segment(origin.partID)}` + if (kind === "reasoning") return { id, type: "group", kind, origin, refs: [origin], completed: false } + return { id, type: "group", kind, origin, refs: [origin], pending: [], completed: false } +} + +function rowKey(row: SessionRow) { + return row.id +} +``` + +`sameRow` answers a different question: can consumers distinguish these two +values for the same identity? It compares only fields that affect row +rendering. + +```typescript +function sameRow(left: SessionRow, right: SessionRow) { + if (left.type !== right.type) return false + + if (left.type === "message" && right.type === "message") return left.messageID === right.messageID + + if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID + + if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref) + + if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID + + if (left.type !== "group" || right.type !== "group") return false + if (left.kind !== right.kind) return false + if (left.completed !== right.completed) return false + if (!sameRefs(left.refs, right.refs)) return false + + if (left.kind === "reasoning" || right.kind === "reasoning") return true + return sameRefs(left.pending, right.pending) +} + +function sameRefs(left: PartRef[], right: PartRef[]) { + return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index])) +} + +function sameRef(left: PartRef, right: PartRef) { + return left.messageID === right.messageID && left.partID === right.partID +} +``` + +Those functions form the complete reconciliation policy: + +```typescript +const rows = Keyed.make({ + 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 `` 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: 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. + 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` 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. diff --git a/docs/design/quark-review-recommendations.md b/docs/design/quark-review-recommendations.md new file mode 100644 index 000000000000..c38bfb1c7260 --- /dev/null +++ b/docs/design/quark-review-recommendations.md @@ -0,0 +1,137 @@ +# Quark Timeline Experiment: Review Recommendations + +Status: point-in-time review feedback; accepted performance and correctness findings implemented +Reviewed: `docs/design/quark-performance-model.md`, `docs/design/quark-tui-timeline.md`, `packages/quark/src/*`, `packages/tui/src/routes/session/rows.ts`, `packages/tui/src/routes/session/index.tsx` + +Goal: really fast and really beautiful. These are ordered recommendations for the next agent. Each item is independently actionable; the order reflects priority. + +This document preserves the audit as written. The current implementation has +checked benchmarks and adverse workloads, structural-only boundaries, +precomputed IDs, shared grouping policy, filtered permission repartition, +optional publication counters, deterministic counter tests, and an `O(1)` +seen-part duplicate path. Current evidence and commands live in +`quark-performance-model.md` and `quark-tui-timeline.md`; claims below describe +the pre-fix state. Optional API polish and broader model/index/layout work are +deferred to avoid expanding the timeline landing. + +## 1. Check in the microbenchmark + +`quark-performance-model.md` cites concrete numbers (177.2µs vs 863.7µs, etc.) but the benchmark source is not in the tree. Unreproducible numbers are the weakest part of an otherwise careful doc. + +- Add the benchmark under `packages/quark/bench/keyed.bench.ts` (or similar). +- Add the exact repro command to the "Controlled Benchmark Evidence" section of `quark-performance-model.md`. +- Add the adverse workloads promised in "When Quark Should Lose" (tiny lists, unstable keys, dense mutation, no subscribers, direct `setStore(index, "field", value)` on the Solid side). That section is currently a promise, not a suite. + +## 2. Benchmark the actual hot path: incremental `update` with subscribed `values` + +The checked-in benchmark story compares `Keyed.set(nextArray)` vs Solid `reconcile(nextArray)`. But after the migration, the streaming hot path is **incremental**: `state.update({...previous, refs: [...refs, ref]})` per delta (`packages/tui/src/routes/session/rows.ts`). Whole-array `set` only runs on reconnect/revert/compaction. + +Add a workload: one `update` on a group row, with `values` subscribed, at 100 / 1,000 / 10,000 rows. This measures what the TUI actually does per streaming delta. + +## 3. Fix the hidden O(N) per value change through `values` + `boundaries` + +The performance-model doc presents `values` as "lazy without subscribers," but in the real integration it is **always subscribed**: + +```ts +// packages/tui/src/routes/session/index.tsx (~line 190) +const boundaries = createMemo(() => messageBoundaryIDs([...rows.values()], messages())) +``` + +So every value-only change that survives `sameRow` pays: + +- O(N) recompute of the `values` computed (N slot reads + dependency tracking, `packages/quark/src/keyed.ts:22-25`) +- O(N) `same` compare +- O(N) array spread in the memo +- a full `messageBoundaryIDs` recompute + +The structural cutoff for `` 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` 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) diff --git a/docs/design/quark-tui-timeline.md b/docs/design/quark-tui-timeline.md new file mode 100644 index 000000000000..639ab6bb36ac --- /dev/null +++ b/docs/design/quark-tui-timeline.md @@ -0,0 +1,426 @@ +# Quark-Owned TUI Timeline Rows + +Status: experiment in progress + +## Summary + +The V2 TUI currently stores its rendered session-row list in a Solid Store. +This experiment changes only that owner: `createSessionRows` stores an ordered +array of stable row slots in Quark state. Each slot owns one `SessionRow`, and +an owner-aware adapter exposes both levels to the existing Solid renderer. + +The event protocol, durable session data, `DataProvider`, row reduction rules, +and `SessionRowView` remain unchanged. This boundary makes the experiment easy +to compare and easy to remove. It does not yet move messages or secondary +indexes into Quark collections. + +The experiment succeeds only if all three checks pass: + +1. Existing TUI timeline tests preserve their behavior. +2. One `opencode-drive` script passes unchanged against untouched `origin/v2` + and this branch. +3. The Quark path improves measured timeline work without increasing visible + latency or invalidation counts. + +## The Row Owner Is the Experimental Boundary + +`DataProvider` receives server events and projects them into its existing Solid +Store. `createSessionRows` reads that projection, subscribes to timeline events, +and maintains the ordered rows consumed by `SessionRowView`. + +Before this branch, `createSessionRows` also used a Solid Store for its row +state: + +```text +server events + | + v +DataProvider Solid Store + | + v +createSessionRows + | + v +SessionRow[] Solid Store + | + v + -> SessionRowView +``` + +This branch replaces only the second store: + +```text +server events + | + v +DataProvider Solid Store + | + v +createSessionRows + | + v +Quark State + | + 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() -> + | + 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 `` 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. `` 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 `` 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. diff --git a/packages/quark/bench/harness.ts b/packages/quark/bench/harness.ts new file mode 100644 index 000000000000..4b3e4fb5e26e --- /dev/null +++ b/packages/quark/bench/harness.ts @@ -0,0 +1,60 @@ +export type Workload = { + run(index: number): void + consume(): number + dispose?(): void +} + +export type Variant = { + readonly name: string + readonly make: () => Workload +} + +export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) { + const samples = options.samples ?? 9 + const warmup = options.warmup ?? 500 + let checksum = 0 + + return { + samples, + compare(iterations: number, variants: readonly Variant[]) { + const timings = variants.map(() => [] as number[]) + for (let sample = -1; sample < samples; sample++) { + const offset = sample < 0 ? 0 : sample % variants.length + variants + .map((_variant, index) => (index + offset) % variants.length) + .forEach((variantIndex) => { + const workload = variants[variantIndex].make() + for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index) + const start = Bun.nanoseconds() + for (let index = 0; index < iterations; index++) workload.run(index) + const elapsed = Bun.nanoseconds() - start + checksum += workload.consume() + workload.dispose?.() + if (sample >= 0) timings[variantIndex].push(elapsed / iterations) + }) + } + + const medians = variants.map((variant, index) => { + const median = middle(timings[index]) + const mad = middle(timings[index].map((value) => Math.abs(value - median))) + const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_") + console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op +/- ${mad.toFixed(1)} MAD`) + console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`) + return median + }) + return { + medians, + ratio(left: number, right: number) { + return middle(timings[left].map((value, index) => value / timings[right][index])) + }, + } + }, + finish() { + console.log(`CHECKSUM ${checksum}`) + }, + } +} + +function middle(values: number[]) { + return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)] +} diff --git a/packages/quark/bench/keyed.ts b/packages/quark/bench/keyed.ts new file mode 100644 index 000000000000..3c0628ee7304 --- /dev/null +++ b/packages/quark/bench/keyed.ts @@ -0,0 +1,217 @@ +import { createComputed, createRoot } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { Keyed } from "../src" +import { createHarness, type Workload } from "./harness" + +type Item = { + readonly id: number + readonly value: number +} + +const bench = createHarness() +const results: Array<{ readonly name: string; readonly ratio: number }> = [] + +function initial(size: number) { + return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 })) +} + +function project(values: readonly Item[]) { + return values.reduce((total, value) => total + value.id + value.value, 0) +} + +function quarkDirect(size: number, aggregate: boolean): Workload { + const values = initial(size) + const keyed = Keyed.make({ + 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({ + 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({ + 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({ 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() diff --git a/packages/quark/bench/row-key.ts b/packages/quark/bench/row-key.ts new file mode 100644 index 000000000000..0e9a76371fde --- /dev/null +++ b/packages/quark/bench/row-key.ts @@ -0,0 +1,63 @@ +import { createHarness, type Workload } from "./harness" + +type Row = + | { readonly type: "message"; readonly messageID: string } + | { readonly type: "part"; readonly messageID: string; readonly partID: string } + | { + readonly type: "group" + readonly kind: "reasoning" | "exploration" + readonly messageID: string + readonly partID: string + } + +const size = 10_000 +const iterations = 1_000_000 +const rows = Array.from({ length: size }, (_, index): Row => { + if (index % 3 === 0) return { type: "message", messageID: `message-${index}` } + if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` } + return { + type: "group", + kind: index % 2 === 0 ? "reasoning" : "exploration", + messageID: `message-${index >> 2}`, + partID: `call-${index}`, + } +}) +const precomputed = rows.map((row) => ({ row, id: concatenate(row) })) +const bench = createHarness() + +function workload(read: (index: number) => string): Workload { + let sink = 0 + return { + run(index) { + sink += read(index % size).length + }, + consume: () => sink, + } +} + +function json(row: Row) { + if (row.type === "message") return JSON.stringify([row.type, row.messageID]) + if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID]) + return JSON.stringify([row.type, row.kind, row.messageID, row.partID]) +} + +function concatenate(row: Row) { + if (row.type === "message") return `m${row.messageID.length}:${row.messageID}` + if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}` + return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}` +} + +console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`) + +const result = bench.compare(iterations, [ + { name: "JSON tuple key", make: () => workload((index) => json(rows[index])) }, + { name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) }, + { name: "Precomputed key", make: () => workload((index) => precomputed[index].id) }, +]) + +console.log("\nRatios to JSON tuple (lower is faster)") +console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`) +console.log(`Precomputed: ${result.ratio(2, 0).toFixed(3)}x`) +console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`) +bench.finish() diff --git a/packages/quark/package.json b/packages/quark/package.json new file mode 100644 index 000000000000..1548fcbb5317 --- /dev/null +++ b/packages/quark/package.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "effect-quark", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./solid": "./src/solid.ts" + }, + "scripts": { + "bench:keyed": "bun --conditions=browser bench/keyed.ts", + "bench:row-key": "bun --conditions=browser bench/row-key.ts", + "test": "bun --conditions=browser test" + }, + "dependencies": { + "alien-signals": "3.2.1", + "solid-js": "catalog:" + } +} diff --git a/packages/quark/src/index.ts b/packages/quark/src/index.ts new file mode 100644 index 000000000000..89b79d8c6eae --- /dev/null +++ b/packages/quark/src/index.ts @@ -0,0 +1,2 @@ +export { Keyed } from "./keyed" +export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity" diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts new file mode 100644 index 000000000000..dff27b17d4ac --- /dev/null +++ b/packages/quark/src/keyed.ts @@ -0,0 +1,143 @@ +import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity" + +export namespace Keyed { + export type Position = "end" | { readonly before: Key } | { readonly after: Key } + + export interface Metrics { + slotPublications: number + structuralPublications: number + equivalenceSuppressions: number + } + + export interface Keyed { + readonly slots: Readable[]> + readonly values: Readable + has(key: Key): boolean + get(key: Key): Readable | undefined + set(values: readonly A[]): void + update(value: A): boolean + insert(value: A, position?: Position): Readable + remove(key: Key): boolean + move(key: Key, position?: Position): boolean + } + + export function make(options: { + readonly key: (value: A) => Key + readonly equivalent?: (left: A, right: A) => boolean + readonly metrics?: Metrics + }): Keyed { + const slots = State.make[]>([]) + const byKey = new Map>() + const equivalent = options.equivalent ?? Object.is + const values = Computed.make((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[], position?: Position) { + 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[], 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(left: readonly A[] | undefined, right: readonly A[]) { + return left?.length === right.length && left.every((value, index) => Object.is(value, right[index])) + } +} diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts new file mode 100644 index 000000000000..4cb290d2bdf2 --- /dev/null +++ b/packages/quark/src/reactivity.ts @@ -0,0 +1,59 @@ +import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals" + +export interface Readable { + (): A + subscribe(listener: (value: A) => void): () => void +} + +export interface Writable extends Readable { + set(value: A): void + update(f: (value: A) => A): void +} + +function subscribe(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(initial: A): Writable { + const state = signal(initial) + const read = (() => state()) as Writable + 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(evaluate: (previous: A | undefined) => A): Readable { + const value = computed(evaluate) + const read = (() => value()) as Readable + read.subscribe = (listener) => subscribe(read, listener) + return read + } +} + +export namespace Transaction { + export function run(f: () => A): A { + startBatch() + try { + return f() + } finally { + endBatch() + } + } +} diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts new file mode 100644 index 000000000000..2ca1ecf7bd11 --- /dev/null +++ b/packages/quark/src/solid.ts @@ -0,0 +1,22 @@ +import { For, from, type Accessor, type JSX } from "solid-js" +import type { Readable } from "./reactivity" + +export function useValue(readable: Readable): Accessor { + return from(readable, readable()) +} + +export function KeyedFor(props: { + readonly each: Accessor[]> + readonly fallback?: JSX.Element + readonly children: (value: Accessor, index: Accessor) => JSX.Element +}) { + return For({ + get each() { + return props.each() + }, + get fallback() { + return props.fallback + }, + children: (slot, index) => props.children(useValue(slot), index), + }) +} diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts new file mode 100644 index 000000000000..64f9ec23b6cd --- /dev/null +++ b/packages/quark/test/keyed.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "bun:test" +import { Computed, Keyed } from "../src" + +type Item = { + readonly id: number + readonly label: string +} + +const item = (id: number, label: string): Item => ({ id, label }) + +describe("Keyed", () => { + it("keeps slots stable while publishing value and structural changes separately", () => { + const keyed = Keyed.make({ 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({ + 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({ 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({ 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({ 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({ 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({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const structure = keyed.slots() + const two = keyed.slots()[1] + const structures: Array = [] + 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({ 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({ 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({ 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({ 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) + }) +}) diff --git a/packages/quark/test/reactivity.test.ts b/packages/quark/test/reactivity.test.ts new file mode 100644 index 000000000000..66c1f479bcdb --- /dev/null +++ b/packages/quark/test/reactivity.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test" +import { createEffect, createRoot } from "solid-js" +import { State } from "../src" +import { useValue } from "../src/solid" + +describe("Quark", () => { + it("does not track state read by subscription listeners", () => { + const source = State.make(1) + const unrelated = State.make(1) + const values: number[] = [] + const dispose = source.subscribe((value) => { + unrelated() + values.push(value) + }) + + source.set(2) + unrelated.set(2) + + expect(values).toEqual([2]) + dispose() + }) + + it("bridges values into a Solid owner and disposes with it", () => { + const source = State.make(1) + const values: number[] = [] + let dispose = () => {} + createRoot((rootDispose) => { + dispose = rootDispose + const value = useValue(source) + createEffect(() => values.push(value())) + }) + + source.set(2) + dispose() + source.set(3) + + expect(values).toEqual([1, 2]) + }) +}) diff --git a/packages/tui/package.json b/packages/tui/package.json index 931c716d954b..e9f02d665274 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -86,6 +86,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", + "effect-quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index adeb207940bd..31a49a532045 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -13,6 +13,7 @@ import { Switch, useContext, } from "solid-js" +import { KeyedFor } from "effect-quark/solid" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -186,7 +187,7 @@ export function Session() { const client = useClient() 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() const [navigationSlack, setNavigationSlack] = createSignal(0) @@ -903,15 +904,15 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(row, index) => ( data.session.message.get(route.sessionID, messageID)} boundaryID={boundaries()[index()]} /> )} - + ) { +export function createSessionRows(sessionID: Accessor, options?: { readonly metrics?: Keyed.Metrics }) { const data = useData() const client = useClient() - const [rows, setRows] = createStore([]) + 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() + 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) { ...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) { 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) { // 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) { .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) { ] : [], ), - () => 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) { 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) { }), ] 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, "type" | "messageID"> + | Pick, "type"> + | Pick, "type" | "ref"> + | Pick, "type" | "origin"> + | Pick, "type" | "messageID"> + +export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) { const byID = new Map(messages.map((message) => [message.id, message])) const seen = new Set() return rows.map((row) => { @@ -296,7 +358,7 @@ export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageI }) } -function rowBoundaryMessageID(row: SessionRow, messages: Map) { +function rowBoundaryMessageID(row: BoundaryRow, messages: Map) { 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 { - 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 } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index c35138676a9a..1587944a20a7 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -28,6 +28,19 @@ async function wait(fn: () => boolean, timeout = 2000) { } } +function readRows(rows: ReturnType) { + 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, event: OpenCodeEvent) { events.emit({ ...event, location: { directory } }) } @@ -687,10 +700,16 @@ test("completes exploration when a queued prompt is promoted", async () => { }, events) let rows!: ReturnType let client!: ReturnType + 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 } @@ -732,31 +751,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() } @@ -804,8 +873,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 + let rows!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + rows = createSessionRows(() => sessionID, { metrics }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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() } @@ -957,13 +1108,15 @@ test("tracks session status from active sessions and execution events", async () }) }, events) let data!: ReturnType - let rows!: SessionRow[] - let manualRows!: SessionRow[] + let rows!: ReturnType + let manualRows!: ReturnType + let liveRows!: ReturnType function Probe() { data = useData() rows = createSessionRows(() => "session-retry") manualRows = createSessionRows(() => "session-manual") + liveRows = createSessionRows(() => "session-live") return } @@ -1159,7 +1312,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, @@ -1176,7 +1329,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", @@ -1231,7 +1386,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, @@ -1243,10 +1400,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, ) @@ -1273,7 +1432,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", @@ -1291,10 +1453,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() } @@ -1353,8 +1517,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" }, ]) @@ -1371,8 +1539,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", diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c84237a3b768..c69b027a197c 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows" +import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows" + +const withoutIDs = (rows: ReturnType) => + 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" }, diff --git a/script/quark-group-drive.ts b/script/quark-group-drive.ts new file mode 100644 index 000000000000..78839beaf6ec --- /dev/null +++ b/script/quark-group-drive.ts @@ -0,0 +1,76 @@ +import { Effect, Stream } from "effect" +import { defineScript, Llm } from "opencode-drive" + +const marker = "QUARK_GROUP_COMPLETE" + +export default defineScript({ + project: { + git: true, + files: { + "src/one.ts": "export const one = 1\n", + "src/two.ts": "export const two = 2\n", + }, + }, + config: { + permissions: [ + { action: "*", resource: "*", effect: "ask" }, + { action: "read", resource: "*one.ts", effect: "allow" }, + ], + }, + tui: { viewport: { cols: 120, rows: 36 }, recording: true }, + run: ({ llm, ui }) => + Effect.gen(function* () { + yield* ui.waitFor((state) => state.focused.editor) + let attempt = 0 + yield* llm.serve(() => { + const current = attempt++ + if (current === 0) + return Stream.make( + Llm.toolCall({ + index: 0, + id: "call_read_one", + name: "read", + input: { path: "src/one.ts" }, + }), + Llm.finish("tool-calls"), + ) + if (current === 1) + return Stream.make( + Llm.toolCall({ + index: 0, + id: "call_read_two", + name: "read", + input: { path: "src/two.ts" }, + }), + Llm.finish("tool-calls"), + ) + return Stream.make(Llm.text(marker), Llm.finish("stop")) + }) + yield* ui.submit("Inspect both source modules, then finish.") + yield* ui.waitFor("Permission required", { timeout: 15_000 }) + const frame = yield* ui.capture() + const row = frame.lines.findIndex((line) => line.spans.some((span) => span.text.includes("Exploring"))) + if (row === -1) throw new Error("the exploration summary was not visible") + const column = 8 + const candidates = (yield* ui.state()).elements.filter( + (element) => + element.x <= column && + element.x + element.width > column && + element.y <= row && + element.y + element.height > row, + ) + if (candidates.length === 0) throw new Error("the exploration summary had no renderable target") + const summary = candidates.reduce((smallest, element) => + element.width * element.height < smallest.width * smallest.height ? element : smallest, + ) + yield* ui.click(summary, { x: column - summary.x, y: row - summary.y }) + yield* ui.waitFor("src/one.ts") + const before = yield* ui.screenshot("group-expanded-pending") + yield* ui.enter() + yield* ui.waitFor(marker, { timeout: 15_000 }) + yield* ui.waitFor("src/two.ts") + const after = yield* ui.screenshot("group-expanded-complete") + yield* Effect.sync(() => console.log(`ARTIFACT group_before=${before}`)) + yield* Effect.sync(() => console.log(`ARTIFACT group_after=${after}`)) + }), +}) diff --git a/script/quark-timeline-drive.ts b/script/quark-timeline-drive.ts new file mode 100644 index 000000000000..c9c671a2eb0e --- /dev/null +++ b/script/quark-timeline-drive.ts @@ -0,0 +1,46 @@ +import { Effect } from "effect" +import { defineScript, Llm } from "opencode-drive" + +const marker = "QUARK_TIMELINE_COMPLETE" +const reasoning = Array.from( + { length: 16 }, + (_, index) => `Reasoning segment ${index + 1} checks streamed timeline updates.`, +).join(" ") +const response = `${Array.from( + { length: 24 }, + (_, index) => `Timeline chunk ${index + 1} remains visible and ordered.`, +).join(" ")} ${marker}` + +export default defineScript({ + project: { + git: true, + files: { + "src/example.ts": "export const timeline = true\n", + }, + }, + tui: { viewport: { cols: 120, rows: 36 } }, + run: ({ opencode, llm, ui }) => + Effect.gen(function* () { + yield* ui.waitFor((state) => state.focused.editor) + yield* llm.title(() => Effect.succeed("Timeline comparison")) + const started = Date.now() + yield* ui.submit("Explain how this project handles its timeline.") + yield* llm.send( + Llm.reasoning(reasoning, { delay: 1, chunkSize: 8 }), + Llm.text(response, { delay: 1, chunkSize: 8 }), + ) + yield* ui.waitFor(marker, { timeout: 30_000 }) + const session = (yield* opencode.session.list({})).data[0] + if (!session) throw new Error("the Drive session was not projected") + const assistant = (yield* opencode.message.list({ sessionID: session.id })).data.findLast( + (message) => message.type === "assistant", + ) + if (assistant?.type !== "assistant") throw new Error("the assistant message was not projected") + if (assistant.content.find((part) => part.type === "reasoning")?.text !== reasoning) + throw new Error("the projected reasoning content did not match") + if (assistant.content.find((part) => part.type === "text")?.text !== response) + throw new Error("the projected text content did not match") + if (assistant.finish !== "stop") throw new Error("the projected assistant message did not finish") + yield* Effect.sync(() => console.log(`METRIC tui_timeline_drive_ms=${Date.now() - started}`)) + }), +}) From 53b236d2a8603bd7cf647e2c0d2ffa46ea05466e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 21:47:34 -0400 Subject: [PATCH 02/18] refactor(tui): extract session timeline state --- docs/design/quark-architecture-review.md | 83 +-- docs/design/quark-performance-model.md | 286 ++++---- docs/design/quark-tui-timeline.md | 171 +++-- packages/quark/src/index.ts | 1 + packages/quark/src/keyed.ts | 55 +- packages/quark/src/layout.ts | 660 ++++++++++++++++++ packages/quark/test/keyed.test.ts | 18 + packages/quark/test/layout.test.ts | 253 +++++++ packages/tui/bench/session-timeline.ts | 120 ++++ packages/tui/package.json | 1 + packages/tui/src/routes/session/index.tsx | 34 +- packages/tui/src/routes/session/rows.ts | 323 +-------- packages/tui/src/routes/session/timeline.ts | 328 +++++++++ packages/tui/test/cli/tui/data.test.tsx | 3 +- .../tui/test/cli/tui/session-rows.test.ts | 163 ++++- 15 files changed, 1907 insertions(+), 592 deletions(-) create mode 100644 packages/quark/src/layout.ts create mode 100644 packages/quark/test/layout.test.ts create mode 100644 packages/tui/bench/session-timeline.ts create mode 100644 packages/tui/src/routes/session/timeline.ts diff --git a/docs/design/quark-architecture-review.md b/docs/design/quark-architecture-review.md index 45da7f1c78fc..28607534deeb 100644 --- a/docs/design/quark-architecture-review.md +++ b/docs/design/quark-architecture-review.md @@ -1,6 +1,6 @@ # 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. +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 @@ -34,13 +34,13 @@ 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: +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: +`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... */ ) @@ -58,7 +58,7 @@ function rowKey(row: SessionRow) { } ``` -`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**. +`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 @@ -66,8 +66,8 @@ A Solid Store exposes one reactive graph; every consumer subscribes into the sam ```typescript title="packages/quark/src/keyed.ts" start=4 export interface Keyed { - readonly slots: Readable[]> // fires ONLY on insert/remove/reorder - readonly values: Readable // fires on any current value change + readonly slots: Readable[]> // fires ONLY on insert/remove/reorder + readonly values: Readable // fires on any current value change has(key: Key): boolean get(key: Key): Readable | undefined set(values: readonly A[]): void @@ -98,13 +98,15 @@ 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)) -})) +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, @@ -123,10 +125,10 @@ export function make(options: { readonly equivalent?: (left: A, right: A) => boolean }): Keyed { const slots = State.make[]>([]) // structure channel: one signal holding the slot array - const byKey = new Map>() // identity → slot address, maintained by set/insert/remove - const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI) + const byKey = new Map>() // identity → slot address, maintained by set/insert/remove + const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI) const values = Computed.make((previous) => { - const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed + const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed return same(previous, next) ? previous! : next }) @@ -135,11 +137,11 @@ export function make(options: { 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 + 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 + slot.set(value) // 4. one signal write; slots is untouched return true }, // ... @@ -169,20 +171,21 @@ const groupSlot = structure[0] rows.update({ ...initial, completed: true }) -rows.slots() === structure // true — receives no change at all -rows.slots()[0] === groupSlot // true — component owner survives -groupSlot().completed === true // true — the one subscribed row re-renders +rows.slots() === structure // true — 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. +The compiled `parts` members index contains every visible part identity, +including refs nested inside groups. A duplicate streaming delta dies at +`state.hasMember("parts", 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 `modify`, generated equivalence remains the +final publication cutoff. ## More Before / After, From the Actual Diff @@ -226,12 +229,12 @@ 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 + 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. +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 @@ -260,18 +263,18 @@ setRows(reconcile(reduce())) 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*. +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; `` silent | -| Structure changed | full reconcile | one new array; retained slots reused by reference | -| Batch of writes | store batching | one `Transaction.run` — settled publication | +| 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; `` 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. @@ -281,7 +284,7 @@ Same asymptotics — `O(N)` for a whole-array `set` — but far fewer instructio - **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. +- **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 @@ -292,7 +295,7 @@ Every leak identified in the first pass has since been closed: - ~~**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. +- ~~**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. diff --git a/docs/design/quark-performance-model.md b/docs/design/quark-performance-model.md index 84abf5175e1b..3220fd4dba4f 100644 --- a/docs/design/quark-performance-model.md +++ b/docs/design/quark-performance-model.md @@ -80,37 +80,45 @@ 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 +`Layout.collection` compiles identity, equivalence, and indexes once when the +timeline is created. 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 -} +const PartRefLayout = Layout.struct({ + messageID: Layout.string, + partID: Layout.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 } -) +const SessionRowLayout = Layout.keyedUnion({ + key: Layout.key("id", Layout.string), + tag: "type", + variants: { + message: Layout.struct({ messageID: Layout.string }), + "compaction-queued": Layout.struct({ inputID: Layout.string }), + part: Layout.struct({ ref: PartRefLayout }), + group: Layout.union({ + tag: "kind", + variants: { + reasoning: Layout.struct({ + origin: Layout.immutable(PartRefLayout), + refs: Layout.array(PartRefLayout), + completed: Layout.boolean, + }), + exploration: Layout.struct({ + origin: Layout.immutable(PartRefLayout), + refs: Layout.array(PartRefLayout), + pending: Layout.array(PartRefLayout), + completed: Layout.boolean, + }), + }, + }), + "assistant-footer": Layout.struct({ messageID: Layout.string }), + }, +}) + +type PartRef = Layout.Type +type SessionRow = Layout.Type ``` Factories build the ID once with length-prefixed segments. Length prefixes @@ -126,56 +134,28 @@ function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRo 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. +Generated equivalence answers a different question: can consumers distinguish +these two values for the same identity? The layout compares rendered fields, +dispatches through the row and group tags, and ignores immutable `origin`. ```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])) -} +const SessionRows = Layout.collection( + SessionRowLayout, + ({ members }) => ({ + parts: members(partIDs), + }), + { backend: "generated" }, +) -function sameRef(left: PartRef, right: PartRef) { - return left.messageID === right.messageID && left.partID === right.partID -} +const rows = SessionRows.make() ``` -Those functions form the complete reconciliation policy: - -```typescript -const rows = Keyed.make({ - key: rowKey, - equivalent: sameRow, -}) - -rows.set(nextRows) -``` +The `parts` index contains standalone and grouped part IDs. Duplicate stream +deltas return through `hasMember` before aggregate projection. Arbitrary +replacements derive membership automatically; the hot group-append path applies +the exact one-member delta already known from the event. It exposes two reactive surfaces: @@ -207,10 +187,7 @@ rows.set([initial]) const structure = rows.slots() const groupSlot = structure[0] -rows.update({ - ...initial, - completed: true, -}) +rows.modify(initial.id, (group) => ({ ...group, completed: true })) rows.slots() === structure // true: receives no structural change rows.slots()[0] === groupSlot // true: component owner survives @@ -269,13 +246,14 @@ The current algorithm is deliberately small: ```text set(next): 1. Compute every next key and reject duplicates. - 2. Build Map. + 2. Look up retained slots in the persistent key map. 3. For each next value: a. Reuse the previous slot for its key, or create one. - b. Compare previous and next values. + b. Run generated equivalence over 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. + 5. Synchronize declared collection indexes. + 6. Flush slot, index, and structural writes in one transaction. ``` The aggregate `values` readable maps the current slots to their values. It is a @@ -322,54 +300,31 @@ These laws are stronger than a plain `Array` 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 +## Layout Compiles Trusted Collection Metadata -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: +The timeline now uses Quark's trusted in-memory `Layout`. It supports the scope +required by this experiment: -- field names into numeric positions; -- field changes into a bit mask; -- field equivalence into precomputed functions; -- entity keys and indexed fields into fixed metadata. +- primitive, array, struct, discriminated-union, and keyed-union fields; +- immutable fields excluded from rendered equivalence; +- compiled key extraction and closure or generated equivalence; +- imperative multi-value membership and ordered first-match indexes; +- automatic index derivation for arbitrary replacements; +- typed member deltas for event-native mutations that know exact changes. -The minimal API marks the key in the shape itself: +Generated nested union comparison measured `1.06x-1.10x` handwritten cost, +versus `1.19x-1.22x` for closure Layout across three runs. Generation occurs +once when `SessionRows` is declared. The older `1.322x` closure result motivated +this backend; it no longer describes the production comparator. -```typescript -const ItemLayout = Layout.struct({ - id: Layout.key(Layout.number), - value: Layout.number, -}) +Automatic membership derivation is linear in an affected row's members. That +is the safe default, but it made repeated append to one growing group roughly +`27x-30x` the handwritten index path. The event already identifies the new +part, so `SessionTimeline.appendPart` supplies a typed one-member delta. Generic +completion and permission repartition retain automatic derivation. -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. +Numeric field positions, change masks, per-field reactive slots, models, and +columnar storage remain future research. They are not required by the TUI. ## Asymptotics and Constants @@ -388,11 +343,12 @@ complexity**: - 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`. +key lets `has`, `get`, `hasMember`, and `modify` find their target in expected +`O(1)` time. Work is then proportional to the changed row, affected declared +indexes, and any ordered structural array copy. `SessionTimeline` caches the +active group and queued boundary IDs; inserts and removals remain `O(N)` because +the ordered slots array must be copied. Full synchronization and revert rebuilds +remain linear `set` operations. ## Controlled Benchmark Evidence @@ -402,6 +358,9 @@ The benchmarks are checked into the fork and run directly: cd packages/quark bun run bench:keyed bun run bench:row-key + +cd ../tui +bun run bench:timeline ``` Each invocation interleaves and rotates variants over nine measured samples @@ -425,20 +384,21 @@ Across these two invocations: 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 | +The refreshed lower-level `Keyed` benchmark measures event-native updates with +the aggregate `values` channel subscribed, plus adverse workloads. It does not +include `SessionTimeline`, generated row equivalence, or compiled index +maintenance. 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` @@ -455,6 +415,24 @@ 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. +The checked-in `SessionTimeline` benchmark compares the final domain module to +the previous handwritten `Keyed + Set` reducer mechanics and the original Solid +Store `produce` path. Across three runs: + +| Workload | SessionTimeline result | +| ----------------------------------------------------- | ----------------------: | +| Growing reasoning-group append / handwritten Quark | about `1.3x-1.5x` | +| Growing reasoning-group append / Solid `produce` | about `0.013x` | +| Duplicate part in a 1,000-ref group / Solid `produce` | about `0.0004x-0.0005x` | + +The final module pays roughly 30%-50% over the minimal handwritten Quark path +for compiled layout dispatch, domain policy, and maintained index ownership. +It remains roughly 74x-79x faster than the baseline Solid append in this +growing-group stress workload. Duplicate lookup is expected `O(1)` through the +compiled members index; the Solid baseline scans 1,000 refs, producing an +approximately 1,960x-2,500x stress-case difference. These are state-operation +results, not claims about total OpenCode response latency. + Key extraction was measured independently over 10,000 representative rows: | Key strategy | Median | Paired ratio to JSON | @@ -468,8 +446,8 @@ 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: +aggregate projection, dense changes, unstable keys, key extraction, growing +timeline groups, and duplicate deltas. It does not prove that Quark beats: - every Solid keyed-list primitive; - rendering, terminal layout, or paint; @@ -500,19 +478,18 @@ 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 +## Deterministic Tests 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: +The single-binary deterministic event trace counts: -| 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 | +| 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 through compiled membership index | | Full unchanged two-row rebuild | +0 | +0 | Two equivalence suppressions, no publication | `Keyed` exposes optional counters for slot publications, structural @@ -524,6 +501,11 @@ 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. +Pure timeline tests additionally cover cached queue advancement, out-of-order +promotion, duplicate message/footer idempotence, stable group ownership, and +parity between event-native operations and snapshot reduction. Unique append +uses cached IDs and never reads `state.values()`. + ## Decision Rule Continue the Quark timeline experiment if deterministic replay confirms all of diff --git a/docs/design/quark-tui-timeline.md b/docs/design/quark-tui-timeline.md index 639ab6bb36ac..993115f0ea27 100644 --- a/docs/design/quark-tui-timeline.md +++ b/docs/design/quark-tui-timeline.md @@ -4,15 +4,17 @@ 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 V2 TUI previously stored its rendered session-row list in a Solid Store. +This experiment changes only that owner: `SessionTimeline` stores an ordered +array of stable row slots in a compiled Quark collection. 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. +to compare and easy to remove. Message content remains in `DataProvider`; +`SessionTimeline` owns a finite imperative index of part IDs for its current +rows. The experiment succeeds only if all three checks pass: @@ -56,10 +58,13 @@ server events DataProvider Solid Store | v -createSessionRows +createSessionRows adapter + | + v +SessionTimeline | v -Quark State +Layout.collection | v KeyedFor outer + slot adapters @@ -74,15 +79,17 @@ 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. | +| 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` | Translate DataProvider state and events into timeline operations; own the Solid adapter lifecycle. | +| `packages/tui/src/routes/session/timeline.ts` | Declare row layout, reduce snapshots, cache cursors, and implement timeline domain mutations. | +| `packages/quark/src/layout.ts` | Compile row keys/equivalence and maintain declared imperative collection indexes. | +| `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 @@ -187,10 +194,11 @@ Most live events update the current row array directly: | 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. +`SessionTimeline` caches the first queued row ID and the active incomplete group +ID. Full replacement derives both once. Live operations maintain them as work +is queued or promoted. New assistant parts and completed messages insert before +the cached queue boundary; pending user inputs append after queued work; a +running compaction becomes the new boundary. `append(rows, ref, part, index)` implements grouping: @@ -214,13 +222,14 @@ because call IDs can look like generated text or reasoning IDs. 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: +The event-native path now calls domain operations backed by the compiled +collection: ```text -value change -> Keyed.update(row) -new row -> Keyed.insert(row, { before }) -removed footer -> Keyed.remove(row.id) -full sync / revert -> Keyed.set(nextRows) +group change -> state.modify(groupID, update) +new row -> state.insert(row, { before }) +removed footer -> state.remove(row.id) +full sync / revert -> state.set(nextRows) ``` Extending or completing a group creates one new group value with copied refs @@ -229,9 +238,11 @@ 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. +Message and footer duplicates use the keyed map. Part duplicates use the +compiled `parts` members index and return before aggregate projection. Unique +group appends use cached IDs and do not read `state.values()`. The append event +also supplies the exact one-member index delta, avoiding a scan of the growing +group; arbitrary replacements retain automatic index derivation. ## `Keyed` Preserves Row Identity @@ -240,24 +251,28 @@ 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. `` therefore receives stable Quark slots rather than row values. -`Keyed.set(nextRows)` preserves ownership in four passes: +`Keyed.set(nextRows)` preserves ownership through its persistent key-to-slot +map: ```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. +1. Validate the next keys. +2. Find each retained slot through the persistent map. +3. Update that slot only when generated row equivalence reports a change. 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}` | +created. `SessionRowLayout` declares that key and generates discriminated-union +equivalence. Group `origin` is immutable metadata; `refs`, `pending`, and +`completed` participate in equivalence. 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. @@ -269,10 +284,13 @@ partitions cannot change its ID or boundary identity. Group equivalence compares order changes, so Solid `` 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. +uses the lazy aggregate for bulk permission repartitioning and snapshot/test +consumers. 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. +The lazy aggregate is read by permission repartitioning and snapshot/test +consumers, not by unique append paths. 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 @@ -285,23 +303,25 @@ from one ordering and an outer slot array from another. 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 | +| Operation | Time | Allocation | +| ---------------------------- | -----------------------: | ---------------------------------------------: | +| Full rebuild | `O(M + P + R)` | New reduction plus reconciled slot array | +| Active group / queue lookup | Expected `O(1)` | None | +| Queue boundary advancement | `O(R)` | None | +| Group extension | `O(G)` | One copied group array; `O(1)` index delta | +| Structural insertion/removal | `O(R)` | One structural slots array | +| Duplicate part append | `O(1)` | One key string | +| Duplicate message/footer | `O(1)` | One key string | +| 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. +existing key map. `Layout.collection` maintains every visible part ID, +including refs nested inside groups. Full replacement rebuilds the finite index; +inserts and arbitrary modifications derive membership automatically; the hot +group append applies one typed member delta. Duplicate streaming deltas return +before reading the lazy aggregate or scanning rows. ## Solid Owns the Adapter Lifecycle @@ -377,7 +397,7 @@ 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: +Seven baseline/fork behavior pairs completed successfully: | Pair | Baseline | Quark fork | Fork / baseline | | ---------------------: | --------: | ---------: | --------------: | @@ -386,7 +406,8 @@ Three alternating smoke pairs completed successfully: | 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 | +| 6, current Drive API | 2,233 ms | 1,413 ms | 0.633x | +| 7, timeline extraction | 973 ms | 825 ms | 0.848x | 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 @@ -394,22 +415,22 @@ 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. +| Check | Current result | +| ------------------------------ | -------------------------------------------------------------------------- | +| TUI package typecheck | Pass | +| Full TUI test suite | 274 pass, 1 skip | +| Focused data and row tests | 55 pass | +| Vendored Quark tests | 24 pass | +| Shared drive scripts typecheck | Pass | +| Shared Drive behavior | Seven 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 | + +## Reactive Query Caches Remain Outside This Phase + +The timeline's imperative members index is finite: entries follow current rows +and are removed synchronously. It is not a reactive query cache and renderers +do not subscribe to it. A later message-collection phase must not delete empty cache entries while a live readable still references them. Safe eviction therefore needs explicit diff --git a/packages/quark/src/index.ts b/packages/quark/src/index.ts index 89b79d8c6eae..dd9db60faeb3 100644 --- a/packages/quark/src/index.ts +++ b/packages/quark/src/index.ts @@ -1,2 +1,3 @@ export { Keyed } from "./keyed" +export { Layout } from "./layout" export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity" diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts index dff27b17d4ac..6bb970aafd05 100644 --- a/packages/quark/src/keyed.ts +++ b/packages/quark/src/keyed.ts @@ -14,11 +14,14 @@ export namespace Keyed { readonly values: Readable has(key: Key): boolean get(key: Key): Readable | undefined - set(values: readonly A[]): void + set(values: readonly A[]): boolean update(value: A): boolean + modify(key: Key, f: (value: A) => A): boolean insert(value: A, position?: Position): Readable remove(key: Key): boolean move(key: Key, position?: Position): boolean + before(key: Key): Readable | undefined + after(key: Key): Readable | undefined } export function make(options: { @@ -44,7 +47,8 @@ export namespace Keyed { const retained = new Set(keys) if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys") - Transaction.run(() => { + return Transaction.run(() => { + let changed = false const previous = slots() const reconciled = next.map((value, index) => { const key = keys[index] @@ -54,12 +58,7 @@ export namespace Keyed { 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++ - } + if (publish(slot, value)) changed = true return slot }) byKey.forEach((_slot, key) => { @@ -68,20 +67,23 @@ export namespace Keyed { if (!same(previous, reconciled)) { slots.set(reconciled) if (options.metrics) options.metrics.structuralPublications++ + changed = true } + return changed }) }, 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 + return publish(slot, value) + }, + modify(key, f) { + const slot = byKey.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + const value = f(slot()) + if (byKey.get(options.key(value)) !== slot) throw new Error("Keyed modify must preserve the value key") + return publish(slot, value) }, insert(value, position) { const key = options.key(value) @@ -118,6 +120,22 @@ export namespace Keyed { if (options.metrics) options.metrics.structuralPublications++ return true }, + before(key) { + return neighbor(key, -1) + }, + after(key) { + return neighbor(key, 1) + }, + } + + function publish(slot: Writable, value: A) { + if (equivalent(slot(), value)) { + if (options.metrics) options.metrics.equivalenceSuppressions++ + return false + } + slot.set(value) + if (options.metrics) options.metrics.slotPublications++ + return true } function positionIndex(current: readonly Writable[], position?: Position) { @@ -131,6 +149,13 @@ export namespace Keyed { if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`) return current.indexOf(target) } + + function neighbor(key: Key, offset: -1 | 1) { + const slot = byKey.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + const current = slots() + return current[current.indexOf(slot) + offset] + } } export function metrics(): Metrics { diff --git a/packages/quark/src/layout.ts b/packages/quark/src/layout.ts new file mode 100644 index 000000000000..f30772d7fa49 --- /dev/null +++ b/packages/quark/src/layout.ts @@ -0,0 +1,660 @@ +import { Keyed } from "./keyed" +import { Transaction, type Readable } from "./reactivity" + +export namespace Layout { + export interface Field { + readonly isKey?: true + readonly primitive?: true + readonly immutable?: true + equivalent(left: A, right: A): boolean + } + + export interface KeyField extends Field { + readonly isKey: true + } + + export interface NamedKey { + readonly name: Name + readonly field: Field + } + + export type Type = Field extends Layout.Field ? A : never + + type Fields = Readonly>> + type Value = { readonly [Key in keyof StructFields]: Type } + type KeyName = { + readonly [Key in keyof StructFields]: StructFields[Key] extends KeyField ? Key : never + }[keyof StructFields] + type Variant = { + readonly [Name in keyof Variants & string]: { readonly [Key in Tag]: Name } & Type + }[keyof Variants & string] + type KeyedVariant = { + readonly [Key in Name]: A + } & Variant + + export interface Struct extends Field> { + readonly type: "struct" + readonly fields: StructFields + } + + export interface Union>>> + extends Field> { + readonly type: "union" + readonly tag: Tag + readonly variants: Variants + } + + export interface KeyedUnion< + Name extends PropertyKey, + A, + Tag extends PropertyKey, + Variants extends Readonly>>, + > extends Field> { + readonly type: "keyed-union" + readonly key: NamedKey + readonly tag: Tag + readonly variants: Variants + } + + export interface Plan { + readonly key: PropertyKey + readonly fields?: Fields + readonly equivalent: (left: A, right: A) => boolean + readonly keyOf: (value: A) => Key + make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed + } + + export interface MembersIndex { + readonly type: "members" + readonly extract: (value: A) => Iterable + } + + export interface FirstIndex { + readonly type: "first" + readonly matches: (value: A) => boolean + } + + export type Index = MembersIndex | FirstIndex + type Indexes = Readonly>> + type MembersNames = { + readonly [Name in keyof Definitions]: Definitions[Name] extends { readonly type: "members" } ? Name : never + }[keyof Definitions] + type FirstNames = { + readonly [Name in keyof Definitions]: Definitions[Name] extends { readonly type: "first" } ? Name : never + }[keyof Definitions] + type Member = Definition extends { readonly extract: (value: never) => Iterable } ? A : never + type MemberChanges = { + readonly [Name in MembersNames]?: { + readonly add?: readonly Member[] + readonly remove?: readonly Member[] + } + } + + export interface IndexBuilder { + members(extract: (value: A) => Iterable): MembersIndex + first(matches: (value: A) => boolean): FirstIndex + } + + export interface Collection> extends Keyed.Keyed { + modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges }): boolean + hasMember>(name: Name, member: Member): boolean + first>(name: Name): Readable | undefined + } + + export interface CollectionPlan> extends Plan { + make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Collection + } + + export const string: Field = primitive() + export const number: Field = primitive() + export const boolean: Field = primitive() + + export function array(item: Field): Field { + return make((left, right) => { + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (!item.equivalent(left[index], right[index])) return false + } + return true + }) + } + + export function immutable(field: Field): Field { + return { ...field, immutable: true, equivalent: () => true } + } + + export function key(field: Field): KeyField + export function key(name: Name, field: Field): NamedKey + export function key(name: Field | PropertyKey, field?: Field): KeyField | NamedKey { + if (field) return { name: name as PropertyKey, field } + return { ...(name as Field), isKey: true } + } + + export function struct(fields: StructFields): Struct { + return { + type: "struct", + fields, + equivalent: compileFields>(fields), + } + } + + export function union< + const Tag extends PropertyKey, + const Variants extends Readonly>>, + >(options: { readonly tag: Tag; readonly variants: Variants }): Union { + const equivalent = compileUnion(options.tag, options.variants) + return { type: "union", ...options, equivalent } + } + + export function keyedUnion< + const Name extends PropertyKey, + A, + const Tag extends PropertyKey, + const Variants extends Readonly>>, + >(options: { + readonly key: NamedKey + readonly tag: Tag + readonly variants: Variants + }): KeyedUnion { + const equivalentVariant = compileUnion(options.tag, options.variants) + const key = (value: KeyedVariant) => value[options.key.name] + const equivalent = (left: KeyedVariant, right: KeyedVariant) => + options.key.field.equivalent(key(left), key(right)) && equivalentVariant(left, right) + return { type: "keyed-union", ...options, equivalent } + } + + export function compile( + layout: Struct, + options?: { readonly backend?: "closure" | "generated" }, + ): Plan, Value[KeyName]> + export function compile< + const Name extends PropertyKey, + A, + const Tag extends PropertyKey, + const Variants extends Readonly>>, + >( + layout: KeyedUnion, + options?: { readonly backend?: "closure" | "generated" }, + ): Plan, A> + export function compile(input: unknown, options: { readonly backend?: "closure" | "generated" } = {}): unknown { + const layout = input as + | Struct + | KeyedUnion>>> + if (layout.type === "keyed-union") { + return makePlan( + layout.key.name, + (value: unknown) => (value as Record)[layout.key.name], + (options.backend === "generated" + ? generateUnion(layout.tag, layout.variants) + : compileUnion(layout.tag, layout.variants)) as (left: unknown, right: unknown) => boolean, + ) + } + + const keys = Reflect.ownKeys(layout.fields).filter((name) => layout.fields[name].isKey) + if (keys.length !== 1) throw new Error("Keyed layout must declare exactly one key field") + const key = keys[0] + const fields = Reflect.ownKeys(layout.fields) + .filter((name) => name !== key && !layout.fields[name].immutable) + .map((name) => ({ name, field: layout.fields[name] })) + const equivalent = + options.backend === "generated" + ? generateEquivalent(generated(fields)) + : compileEquivalent(fields) + return { + fields: layout.fields, + ...makePlan(key, (value: unknown) => (value as Record)[key], equivalent), + } + } + + export function collection>>( + layout: Struct, + define: (index: IndexBuilder>) => Definitions, + options?: { readonly backend?: "closure" | "generated" }, + ): CollectionPlan, Value[KeyName], Definitions> + export function collection< + const Name extends PropertyKey, + A, + const Tag extends PropertyKey, + const Variants extends Readonly>>, + const Definitions extends Indexes>, + >( + layout: KeyedUnion, + define: (index: IndexBuilder>) => Definitions, + options?: { readonly backend?: "closure" | "generated" }, + ): CollectionPlan, A, Definitions> + export function collection( + input: unknown, + define: unknown, + options?: { readonly backend?: "closure" | "generated" }, + ): unknown { + const plan = compile(input as never, options) as Plan + const definitions = (define as (index: IndexBuilder) => Indexes)({ + members: (extract) => ({ type: "members", extract }), + first: (matches) => ({ type: "first", matches }), + }) + return { + ...plan, + make(initial: readonly unknown[] = [], makeOptions?: { readonly metrics?: Keyed.Metrics }) { + return makeCollection(plan, definitions, initial, makeOptions) + }, + } + } + + function makePlan(key: PropertyKey, getKey: (value: A) => Key, equivalent: (left: A, right: A) => boolean) { + return { + key, + equivalent, + keyOf: getKey, + make(initial: readonly A[] = [], options?: { readonly metrics?: Keyed.Metrics }) { + const values = Keyed.make({ key: getKey, equivalent, metrics: options?.metrics }) + values.set(initial) + return values + }, + } + } + + function makeCollection>( + plan: Plan, + definitions: Definitions, + initial: readonly A[], + options?: { readonly metrics?: Keyed.Metrics }, + ): Collection { + const values = plan.make([], options) + type MemberEntry = { + readonly type: "members" + readonly name: PropertyKey + readonly extract: (value: A) => Iterable + readonly counts: Map + readonly byKey: Map; readonly members: Set }> + } + type FirstEntry = { + readonly type: "first" + readonly name: PropertyKey + readonly matches: (value: A) => boolean + readonly matching: Set + slot?: Readable + } + type Entry = MemberEntry | FirstEntry + type Inspection = + | { + readonly type: "members" + readonly value: { readonly source: Iterable; readonly members: Set } + } + | { readonly type: "members-change"; readonly add: readonly unknown[]; readonly remove: readonly unknown[] } + | { readonly type: "first"; readonly value: boolean } + const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => { + const definition = definitions[name] + if (definition.type === "members") { + return { type: "members", name, extract: definition.extract, counts: new Map(), byKey: new Map() } + } + return { type: "first", name, matches: definition.matches, matching: new Set() } + }) + const byName = new Map(entries.map((entry) => [entry.name, entry])) + const emptyMembers: readonly unknown[] = [] + + const collection: Collection = { + ...values, + set(next) { + const keys = next.map(plan.keyOf) + if (new Set(keys).size !== keys.length) return values.set(next) + const prepared = new Map( + next.map((value, index) => { + const key = keys[index] + const previous = values.get(key)?.() + return [key, previous && plan.equivalent(previous, value) ? current(key) : inspect(value, key)] + }), + ) + return Transaction.run(() => { + const changed = values.set(next) + if (!changed) return false + entries.forEach(clear) + values.slots().forEach((slot) => { + const key = plan.keyOf(slot()) + const inspection = prepared.get(key)! + entries.forEach((entry, index) => add(entry, key, slot, inspection[index])) + }) + entries.forEach((entry) => entry.type === "first" && findFirst(entry)) + return true + }) + }, + update(value) { + const key = plan.keyOf(value) + const slot = values.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + const inspection = inspect(value, key) + return Transaction.run(() => { + const changed = values.update(value) + if (changed) entries.forEach((entry, index) => replace(entry, key, slot, inspection[index])) + return changed + }) + }, + modify(key, f, changes) { + const slot = values.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + let inspection: readonly Inspection[] | undefined + return Transaction.run(() => { + const changed = values.modify(key, (previous) => { + const value = f(previous) + if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key") + inspection = inspect(value, key, changes?.members) + return value + }) + if (changed) entries.forEach((entry, index) => replace(entry, key, slot, inspection![index])) + return changed + }) + }, + insert(value, position) { + const key = plan.keyOf(value) + if (values.has(key)) return values.insert(value, position) + requirePosition(position) + const inspection = inspect(value, key) + return Transaction.run(() => { + const slot = values.insert(value, position) + entries.forEach((entry, index) => add(entry, key, slot, inspection[index])) + entries.forEach((entry) => entry.type === "first" && updateFirstAfterPlacement(entry, slot)) + return slot + }) + }, + remove(key) { + const slot = values.get(key) + if (!slot) return false + return Transaction.run(() => { + const removed = values.remove(key) + entries.forEach((entry) => remove(entry, key)) + entries.forEach((entry) => entry.type === "first" && entry.slot === slot && findFirst(entry)) + return removed + }) + }, + move(key, position) { + const slot = values.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + return Transaction.run(() => { + const moved = values.move(key, position) + if (moved) entries.forEach((entry) => entry.type === "first" && updateFirstAfterPlacement(entry, slot)) + return moved + }) + }, + hasMember(name, member) { + const entry = byName.get(normalizeName(name)) + return entry?.type === "members" && entry.counts.has(member) + }, + first(name) { + const entry = byName.get(normalizeName(name)) + return entry?.type === "first" ? entry.slot : undefined + }, + } + collection.set(initial) + return collection + + function inspect(value: A, key: Key, changes?: Readonly>): readonly Inspection[] { + return entries.map((entry) => + entry.type === "members" + ? (() => { + const change = changes?.[entry.name] as + | { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] } + | undefined + if (change) + return { + type: "members-change" as const, + add: change.add ?? emptyMembers, + remove: change.remove ?? emptyMembers, + } + const source = entry.extract(value) + const previous = entry.byKey.get(key) + return { + type: "members" as const, + value: { source, members: source === previous?.source ? previous.members : new Set(source) }, + } + })() + : { type: "first", value: entry.matches(value) }, + ) + } + + function current(key: Key): readonly Inspection[] { + return entries.map((entry) => { + if (entry.type === "members") return { type: "members", value: entry.byKey.get(key)! } + return { type: "first", value: entry.matching.has(key) } + }) + } + + function clear(entry: Entry) { + if (entry.type === "members") entry.byKey.clear() + if (entry.type === "first") entry.matching.clear() + if (entry.type === "members") entry.counts.clear() + if (entry.type === "first") entry.slot = undefined + } + + function add(entry: Entry, key: Key, slot: Readable, inspection: Inspection) { + if (entry.type === "members" && inspection.type === "members") { + entry.byKey.set(key, inspection.value) + inspection.value.members.forEach((member) => entry.counts.set(member, (entry.counts.get(member) ?? 0) + 1)) + return + } + if (entry.type === "first" && inspection.type === "first" && inspection.value) entry.matching.add(key) + } + + function remove(entry: Entry, key: Key) { + if (entry.type === "first") { + entry.matching.delete(key) + return + } + entry.byKey.get(key)?.members.forEach((member) => adjust(entry.counts, member, -1)) + entry.byKey.delete(key) + } + + function replace(entry: Entry, key: Key, slot: Readable, inspection: Inspection) { + if (entry.type === "members" && inspection.type === "members-change") { + const current = entry.byKey.get(key)! + const members = current.members + inspection.remove.forEach((member) => { + if (!members.delete(member)) return + adjust(entry.counts, member, -1) + }) + inspection.add.forEach((member) => { + if (members.has(member)) return + members.add(member) + adjust(entry.counts, member, 1) + }) + entry.byKey.set(key, { source: members, members }) + return + } + if (entry.type === "members" && inspection.type === "members") { + const previous = entry.byKey.get(key)!.members + if (previous === inspection.value.members) { + entry.byKey.set(key, inspection.value) + return + } + inspection.value.members.forEach((member) => !previous.has(member) && adjust(entry.counts, member, 1)) + previous.forEach((member) => !inspection.value.members.has(member) && adjust(entry.counts, member, -1)) + entry.byKey.set(key, inspection.value) + return + } + if (entry.type !== "first" || inspection.type !== "first") return + const previous = entry.matching.has(key) + if (inspection.value) entry.matching.add(key) + if (!inspection.value) entry.matching.delete(key) + if (entry.slot === slot && !inspection.value) findFirst(entry) + if (entry.slot !== slot && !previous && inspection.value) updateFirstAfterPlacement(entry, slot) + } + + function adjust(counts: Map, member: unknown, amount: 1 | -1) { + const count = (counts.get(member) ?? 0) + amount + if (count === 0) counts.delete(member) + if (count > 0) counts.set(member, count) + } + + function updateFirstAfterPlacement(entry: FirstEntry, slot: Readable) { + if (!entry.matching.has(plan.keyOf(slot()))) return + if (!entry.slot) { + entry.slot = slot + return + } + if (entry.slot === slot) return findFirst(entry) + const slots = values.slots() + if (slots.indexOf(slot) < slots.indexOf(entry.slot)) entry.slot = slot + } + + function findFirst(entry: FirstEntry) { + entry.slot = values.slots().find((slot) => entry.matching.has(plan.keyOf(slot()))) + } + + function requirePosition(position?: Keyed.Position) { + if (!position || position === "end") return + const key = "before" in position ? position.before : position.after + if (!values.has(key)) throw new Error(`Keyed value does not exist: ${String(key)}`) + } + + function normalizeName(name: PropertyKey) { + return typeof name === "number" ? String(name) : name + } + } + + function make(equivalent: (left: A, right: A) => boolean): Field { + return { equivalent } + } + + function primitive(): Field { + return { primitive: true, equivalent: Object.is } + } + + function compileUnion>>>( + tag: Tag, + variants: Variants, + ) { + type A = Variant + return (left: A, right: A) => { + const name = left[tag] + if (name !== right[tag] || typeof name !== "string") return false + const variant = variants[name] + return variant ? variant.equivalent(left, right) : false + } + } + + function compileFields(fields: Fields) { + return compileEquivalent( + Reflect.ownKeys(fields) + .filter((name) => !fields[name].immutable) + .map((name) => ({ name, field: fields[name] })), + ) + } + + function generateEquivalent( + fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>, + ) { + if (fields.some((field) => typeof field.name === "symbol")) return compileEquivalent(fields) + const custom: Array["equivalent"]> = [] + const comparisons = fields.map((field) => { + const name = JSON.stringify(String(field.name)) + if (field.field.primitive) return `Object.is(left[${name}], right[${name}])` + const index = custom.push(field.field.equivalent) - 1 + return `custom[${index}](left[${name}], right[${name}])` + }) + const factory = Function("custom", `return (left, right) => ${comparisons.join(" && ") || "true"}`) as ( + custom: ReadonlyArray["equivalent"]>, + ) => (left: A, right: A) => boolean + return factory(custom) + } + + function generateUnion>>>( + tag: Tag, + variants: Variants, + ) { + if (typeof tag === "symbol") return compileUnion(tag, variants) + const names = Object.keys(variants) + const custom = names.map((name) => generateField(variants[name])) + const cases = names + .map((name, index) => `case ${JSON.stringify(name)}: return custom[${index}](left, right)`) + .join(";") + const property = JSON.stringify(String(tag)) + const factory = Function( + "custom", + `return (left, right) => { if (left[${property}] !== right[${property}]) return false; switch (left[${property}]) { ${cases}; default: return false } }`, + ) as (custom: ReadonlyArray["equivalent"]>) => (left: unknown, right: unknown) => boolean + return factory(custom) + } + + function generateField(field: Field): Field["equivalent"] { + if (field.immutable) return () => true + const layout = field as Field & { + readonly type?: "struct" | "union" | "keyed-union" + readonly fields?: Fields + readonly tag?: PropertyKey + readonly variants?: Readonly>> + readonly key?: NamedKey + } + if (layout.type === "struct") { + const fields = Reflect.ownKeys(layout.fields!) + .filter((name) => !layout.fields![name].immutable) + .map((name) => ({ name, field: layout.fields![name] })) + return generateEquivalent(generated(fields)) + } + if (layout.type === "union") return generateUnion(layout.tag!, layout.variants!) + if (layout.type !== "keyed-union") return field.equivalent + const equivalent = generateUnion(layout.tag!, layout.variants!) as (left: unknown, right: unknown) => boolean + return (left, right) => { + const a = left as Record + const b = right as Record + return layout.key!.field.equivalent(a[layout.key!.name], b[layout.key!.name]) && equivalent(left, right) + } + } + + function generated(fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>) { + return fields.map((field) => ({ ...field, field: { ...field.field, equivalent: generateField(field.field) } })) + } + + function compileEquivalent(fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>) { + const value = (input: A) => input as Record + if (fields.length === 0) return (_left: A, _right: A) => true + if (fields.length === 1) { + const first = fields[0] + return (left: A, right: A) => first.field.equivalent(value(left)[first.name], value(right)[first.name]) + } + if (fields.length === 2) { + const first = fields[0] + const second = fields[1] + return (left: A, right: A) => { + const a = value(left) + const b = value(right) + return ( + first.field.equivalent(a[first.name], b[first.name]) && + second.field.equivalent(a[second.name], b[second.name]) + ) + } + } + if (fields.length === 3) { + const first = fields[0] + const second = fields[1] + const third = fields[2] + return (left: A, right: A) => { + const a = value(left) + const b = value(right) + return ( + first.field.equivalent(a[first.name], b[first.name]) && + second.field.equivalent(a[second.name], b[second.name]) && + third.field.equivalent(a[third.name], b[third.name]) + ) + } + } + if (fields.length === 4) { + const first = fields[0] + const second = fields[1] + const third = fields[2] + const fourth = fields[3] + return (left: A, right: A) => { + const a = value(left) + const b = value(right) + return ( + first.field.equivalent(a[first.name], b[first.name]) && + second.field.equivalent(a[second.name], b[second.name]) && + third.field.equivalent(a[third.name], b[third.name]) && + fourth.field.equivalent(a[fourth.name], b[fourth.name]) + ) + } + } + return (left: A, right: A) => { + const a = value(left) + const b = value(right) + return fields.every((field) => field.field.equivalent(a[field.name], b[field.name])) + } + } +} diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts index 64f9ec23b6cd..e7e480bd4b92 100644 --- a/packages/quark/test/keyed.test.ts +++ b/packages/quark/test/keyed.test.ts @@ -131,6 +131,20 @@ describe("Keyed", () => { dispose() }) + it("modifies one existing slot while preserving its key", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one")]) + const slot = keyed.slots()[0] + + expect(keyed.modify(1, (value) => ({ ...value, label: "ONE" }))).toBe(true) + expect(keyed.modify(1, (value) => value)).toBe(false) + + expect(keyed.slots()[0]).toBe(slot) + expect(slot()).toEqual(item(1, "ONE")) + expect(() => keyed.modify(1, (value) => ({ ...value, id: 2 }))).toThrow("Keyed modify must preserve the value key") + expect(() => keyed.modify(2, (value) => value)).toThrow("Keyed value does not exist: 2") + }) + it("checks key membership without reading the aggregate", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one")]) @@ -139,6 +153,8 @@ describe("Keyed", () => { expect(keyed.has(2)).toBe(false) expect(keyed.get(1)).toBe(keyed.slots()[0]) expect(keyed.get(2)).toBeUndefined() + expect(keyed.before(1)).toBeUndefined() + expect(keyed.after(1)).toBeUndefined() keyed.remove(1) expect(keyed.has(1)).toBe(false) expect(keyed.get(1)).toBeUndefined() @@ -154,6 +170,8 @@ describe("Keyed", () => { 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.before(3)).toBe(two) + expect(keyed.after(3)).toBe(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) diff --git a/packages/quark/test/layout.test.ts b/packages/quark/test/layout.test.ts new file mode 100644 index 000000000000..53a70b33c3ca --- /dev/null +++ b/packages/quark/test/layout.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "bun:test" +import { Layout } from "../src" + +const Item = Layout.struct({ + id: Layout.key(Layout.number), + label: Layout.string, +}) + +const Job = Layout.struct({ + id: Layout.key(Layout.string), + labels: Layout.array(Layout.string), + status: Layout.string, +}) + +const Jobs = Layout.collection(Job, ({ members, first }) => ({ + labels: members((job) => job.labels), + nextRetry: first((job) => job.status === "retrying"), +})) + +describe("Layout", () => { + it("compiles a keyed collection from trusted structural metadata", () => { + const plan = Layout.compile(Item) + const original = { id: 1, label: "one" } + const values = plan.make([original]) + const slot = values.slots()[0] + + expect(plan.key).toBe("id") + expect(plan.fields).toBe(Item.fields) + expect(values.update({ id: 1, label: "one" })).toBe(false) + expect(slot()).toBe(original) + expect(values.update({ id: 1, label: "ONE" })).toBe(true) + expect(slot()).toEqual({ id: 1, label: "ONE" }) + }) + + it("requires exactly one key field", () => { + expect(() => Layout.compile(Layout.struct({ value: Layout.number }))).toThrow( + "Keyed layout must declare exactly one key field", + ) + expect(() => + Layout.compile(Layout.struct({ left: Layout.key(Layout.number), right: Layout.key(Layout.number) })), + ).toThrow("Keyed layout must declare exactly one key field") + }) + + it("generates the same trusted equivalence as the closure backend", () => { + const closure = Layout.compile(Item) + const generated = Layout.compile(Item, { backend: "generated" }) + const values = [ + { id: 1, label: "one" }, + { id: 1, label: "ONE" }, + { id: 2, label: "one" }, + ] + + values.forEach((left) => { + values.forEach((right) => { + expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right)) + }) + }) + }) + + it("compiles nested discriminated unions and skips immutable fields", () => { + const Ref = Layout.struct({ messageID: Layout.string, partID: Layout.string }) + const Row = Layout.keyedUnion({ + key: Layout.key("id", Layout.string), + tag: "type", + variants: { + message: Layout.struct({ messageID: Layout.string }), + group: Layout.union({ + tag: "kind", + variants: { + reasoning: Layout.struct({ + origin: Layout.immutable(Ref), + refs: Layout.array(Ref), + completed: Layout.boolean, + }), + exploration: Layout.struct({ + origin: Layout.immutable(Ref), + refs: Layout.array(Ref), + pending: Layout.array(Ref), + completed: Layout.boolean, + }), + }, + }), + }, + }) + const plan = Layout.compile(Row) + const generated = Layout.compile(Row, { backend: "generated" }) + const group = { + id: "group-1", + type: "group" as const, + kind: "exploration" as const, + origin: { messageID: "assistant-1", partID: "read-1" }, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + pending: [] as Array<{ messageID: string; partID: string }>, + completed: false, + } + + expect(plan.equivalent(group, { ...group, origin: { messageID: "ignored", partID: "ignored" } })).toBe(true) + expect(plan.equivalent(group, { ...group, completed: true })).toBe(false) + expect(plan.equivalent(group, { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] })).toBe(false) + expect( + plan.equivalent(group, { + id: "group-1", + type: "group", + kind: "reasoning", + origin: group.origin, + refs: group.refs, + completed: false, + }), + ).toBe(false) + const candidates = [ + group, + { ...group, origin: { messageID: "ignored", partID: "ignored" } }, + { ...group, completed: true }, + { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] }, + { + id: "group-1", + type: "group" as const, + kind: "reasoning" as const, + origin: group.origin, + refs: group.refs, + completed: false, + }, + ] + candidates.forEach((left) => { + candidates.forEach((right) => expect(generated.equivalent(left, right)).toBe(plan.equivalent(left, right))) + }) + }) + + it("includes nested keys in structural equivalence", () => { + const Child = Layout.struct({ id: Layout.key(Layout.number), value: Layout.string }) + const Parent = Layout.struct({ id: Layout.key(Layout.number), child: Child }) + const parent = Layout.compile(Parent) + + expect( + parent.equivalent({ id: 1, child: { id: 1, value: "same" } }, { id: 1, child: { id: 2, value: "same" } }), + ).toBe(false) + }) + + it("composes indexed collections without domain-specific behavior", () => { + const jobs = Jobs.make([ + { id: "one", labels: ["billing", "urgent"], status: "running" }, + { id: "two", labels: ["billing"], status: "retrying" }, + { id: "three", labels: [], status: "retrying" }, + ]) + + expect(jobs.hasMember("labels", "billing")).toBe(true) + expect(jobs.hasMember("labels", "missing")).toBe(false) + expect(jobs.first("nextRetry")?.().id).toBe("two") + expect(jobs.before("two")?.().id).toBe("one") + expect(jobs.after("two")?.().id).toBe("three") + + jobs.modify("one", (job) => ({ ...job, labels: [], status: "retrying" })) + expect(jobs.hasMember("labels", "urgent")).toBe(false) + expect(jobs.hasMember("labels", "billing")).toBe(true) + expect(jobs.first("nextRetry")?.().id).toBe("one") + + jobs.remove("two") + expect(jobs.hasMember("labels", "billing")).toBe(false) + jobs.move("three", { before: "one" }) + expect(jobs.first("nextRetry")?.().id).toBe("three") + }) + + it("keeps indexes synchronized across inserts, updates, and replacement", () => { + const jobs = Jobs.make([{ id: "one", labels: ["one"], status: "running" }]) + + jobs.insert({ id: "three", labels: ["shared"], status: "retrying" }) + jobs.insert({ id: "two", labels: ["shared"], status: "retrying" }, { before: "three" }) + expect(jobs.first("nextRetry")?.().id).toBe("two") + + jobs.update({ id: "two", labels: [], status: "done" }) + expect(jobs.first("nextRetry")?.().id).toBe("three") + expect(jobs.hasMember("labels", "shared")).toBe(true) + + jobs.remove("three") + expect(jobs.first("nextRetry")).toBeUndefined() + expect(jobs.hasMember("labels", "shared")).toBe(false) + + jobs.set([ + { id: "four", labels: ["replacement"], status: "retrying" }, + { id: "five", labels: [], status: "running" }, + ]) + expect(jobs.hasMember("labels", "replacement")).toBe(true) + expect(jobs.hasMember("labels", "one")).toBe(false) + expect(jobs.first("nextRetry")?.().id).toBe("four") + }) + + it("applies explicit member deltas without re-extracting unchanged membership", () => { + let extractions = 0 + const IndexedJobs = Layout.collection(Job, ({ members }) => ({ + labels: members((job) => { + extractions++ + return job.labels + }), + })) + const jobs = IndexedJobs.make([ + { id: "one", labels: ["billing"], status: "running" }, + { id: "two", labels: ["billing"], status: "running" }, + ]) + const before = extractions + + jobs.modify("one", (job) => ({ ...job, labels: ["urgent"] }), { + members: { labels: { add: ["urgent"], remove: ["billing"] } }, + }) + + expect(extractions).toBe(before) + expect(jobs.hasMember("labels", "billing")).toBe(true) + expect(jobs.hasMember("labels", "urgent")).toBe(true) + + jobs.modify("two", (job) => ({ ...job, labels: [] }), { members: { labels: { remove: ["billing"] } } }) + expect(jobs.hasMember("labels", "billing")).toBe(false) + + if (false) { + // @ts-expect-error Member deltas require arrays so string members are not split into characters. + jobs.modify("one", (job) => job, { members: { labels: { add: "urgent" } } }) + } + }) + + it("does not commit mutations when an index callback throws", () => { + const ThrowingJobs = Layout.collection(Job, ({ members, first }) => ({ + labels: members((job) => { + if (job.labels.includes("boom")) throw new Error("boom") + return job.labels + }), + nextRetry: first((job) => job.status === "retrying"), + })) + const jobs = ThrowingJobs.make([{ id: "one", labels: ["safe"], status: "running" }]) + + expect(() => jobs.update({ id: "one", labels: ["boom"], status: "retrying" })).toThrow("boom") + expect(() => jobs.set([{ id: "two", labels: ["boom"], status: "retrying" }])).toThrow("boom") + + expect(jobs.values()).toEqual([{ id: "one", labels: ["safe"], status: "running" }]) + expect(jobs.hasMember("labels", "safe")).toBe(true) + expect(jobs.hasMember("labels", "boom")).toBe(false) + expect(jobs.first("nextRetry")).toBeUndefined() + + expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow( + "Keyed value does not exist: missing", + ) + }) + + it("tracks first matches whose key is undefined", () => { + const undefinedField: Layout.Field = { equivalent: Object.is } + const OptionalJobs = Layout.collection( + Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }), + ({ first }) => ({ retry: first((job) => job.status === "retrying") }), + ) + const jobs = OptionalJobs.make([{ id: undefined, status: "running" }]) + + jobs.update({ id: undefined, status: "retrying" }) + + expect(jobs.first("retry")?.()).toEqual({ id: undefined, status: "retrying" }) + }) +}) diff --git a/packages/tui/bench/session-timeline.ts b/packages/tui/bench/session-timeline.ts new file mode 100644 index 000000000000..adcdfd0483ee --- /dev/null +++ b/packages/tui/bench/session-timeline.ts @@ -0,0 +1,120 @@ +import { Keyed } from "effect-quark" +import { createStore, produce } from "solid-js/store" +import { SessionTimeline, type PartRef } from "../src/routes/session/timeline" +import { createHarness, type Workload } from "../../quark/bench/harness" + +type Group = { + readonly id: "group" + readonly type: "group" + readonly refs: readonly PartRef[] +} + +const bench = createHarness({ warmup: 500 }) + +function timelineAppend(): Workload { + const timeline = SessionTimeline.make() + let ordinal = 0 + return { + run() { + timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal++}` }, { type: "reasoning" }) + }, + consume: () => { + const row = timeline.values()[0] + return row?.type === "group" ? row.refs.length : 0 + }, + } +} + +function keyedAppend(): Workload { + const seen = new Set() + const rows = Keyed.make({ + key: (row) => row.id, + equivalent: (left, right) => + left.refs.length === right.refs.length && + left.refs.every( + (ref, index) => ref.messageID === right.refs[index].messageID && ref.partID === right.refs[index].partID, + ), + }) + rows.set([{ id: "group", type: "group", refs: [] }]) + let ordinal = 0 + return { + run() { + const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` } + if (seen.has(ref.partID)) return + rows.modify("group", (group) => ({ ...group, refs: [...group.refs, ref] })) + seen.add(ref.partID) + }, + consume: () => rows.get("group")!().refs.length, + } +} + +function solidAppend(): Workload { + const [rows, setRows] = createStore>([{ type: "group", refs: [] }]) + let ordinal = 0 + return { + run() { + const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` } + setRows( + produce((draft) => { + if (draft[0].refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return + draft[0].refs.push(ref) + }), + ) + }, + consume: () => rows[0].refs.length, + } +} + +function timelineDuplicate(size: number): Workload { + const timeline = SessionTimeline.make() + Array.from({ length: size }, (_, ordinal) => + timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal}` }, { type: "reasoning" }), + ) + const duplicate = { messageID: "assistant", partID: `reasoning:${size - 1}` } + return { + run: () => timeline.appendPart(duplicate, { type: "reasoning" }), + consume: () => timeline.values().length, + } +} + +function solidDuplicate(size: number): Workload { + const refs = Array.from( + { length: size }, + (_, ordinal): PartRef => ({ messageID: "assistant", partID: `reasoning:${ordinal}` }), + ) + const [rows, setRows] = createStore([{ type: "group" as const, refs }]) + const duplicate = refs.at(-1)! + return { + run() { + setRows( + produce((draft) => { + if (draft[0].refs.some((item) => item.messageID === duplicate.messageID && item.partID === duplicate.partID)) + return + draft[0].refs.push(duplicate) + }), + ) + }, + consume: () => rows.length, + } +} + +console.log(`Session timeline benchmark (${bench.samples} samples)\n`) + +const append = bench.compare(2_000, [ + { name: "SessionTimeline grouped append", make: timelineAppend }, + { name: "Handwritten Keyed + Set append", make: keyedAppend }, + { name: "Solid Store produce append", make: solidAppend }, +]) +const duplicate = bench.compare(10_000, [ + { name: "SessionTimeline duplicate 1000", make: () => timelineDuplicate(1_000) }, + { name: "Solid Store duplicate 1000", make: () => solidDuplicate(1_000) }, +]) + +console.log("\nRatios (lower is faster)") +console.log(`Timeline / handwritten append: ${append.ratio(0, 1).toFixed(3)}x`) +console.log(`Timeline / Solid append: ${append.ratio(0, 2).toFixed(3)}x`) +console.log(`Timeline / Solid duplicate: ${duplicate.ratio(0, 1).toFixed(3)}x`) +console.log(`METRIC timeline_handwritten_append_ratio=${append.ratio(0, 1).toFixed(6)}`) +console.log(`METRIC timeline_solid_append_ratio=${append.ratio(0, 2).toFixed(6)}`) +console.log(`METRIC timeline_solid_duplicate_ratio=${duplicate.ratio(0, 1).toFixed(6)}`) +bench.finish() diff --git a/packages/tui/package.json b/packages/tui/package.json index e9f02d665274..655f980e1c1d 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -6,6 +6,7 @@ "type": "module", "license": "MIT", "scripts": { + "bench:timeline": "bun --conditions=browser bench/session-timeline.ts", "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 31a49a532045..212ab7ee04a4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -73,7 +73,8 @@ import { PluginSlot } from "../../plugin/context" import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" -import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows" +import { createSessionRows } from "./rows" +import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline" import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" @@ -187,7 +188,12 @@ export function Session() { const client = useClient() const editor = useEditorContext() const rows = createSessionRows(() => route.sessionID) - const boundaries = createMemo(() => messageBoundaryIDs(rows.slots().map((slot) => slot()), messages())) + const boundaries = createMemo(() => + messageBoundaryIDs( + rows.slots().map((slot) => slot()), + messages(), + ), + ) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) @@ -1133,7 +1139,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) } function SessionReasoningGroupView(props: { - refs: PartRef[] + refs: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined }) { @@ -1254,8 +1260,8 @@ function SessionReasoningGroupView(props: { } function SessionGroupView(props: { - refs: PartRef[] - pending: PartRef[] + refs: readonly PartRef[] + pending: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined }) { @@ -1264,7 +1270,7 @@ function SessionGroupView(props: { const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - const parts = (refs: PartRef[]) => + const parts = (refs: readonly PartRef[]) => refs.flatMap((ref) => { const message = props.message(ref.messageID) if (message?.type !== "assistant") return [] @@ -1785,7 +1791,9 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole - + {Locale.titlecase(props.message.agent)} · {model()} @@ -2338,9 +2346,7 @@ function BlockTool(props: { paddingBottom={1} paddingLeft={2} gap={1} - backgroundColor={ - hover() ? themeV2.raise(themeV2.background()) : themeV2.background() - } + backgroundColor={hover() ? themeV2.raise(themeV2.background()) : themeV2.background()} customBorderChars={SplitBorder.customBorderChars} borderColor={themeV2.background()} onMouseOver={() => props.onClick && setHover(true)} @@ -2357,9 +2363,13 @@ function BlockTool(props: { {(title) => ( {title()}} + fallback={ + {title()} + } > - {title().replace(/^# /, "")} + + {title().replace(/^# /, "")} + )} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index ead696cacda0..0af3239b0d60 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,79 +1,48 @@ -import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { Keyed, Transaction } from "effect-quark" +import { Keyed } 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 = { - readonly messageID: string - readonly partID: string -} - -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 } -) +import { + SessionTimeline, + compactionQueuedRow, + isTerminalFinish, + reduceSessionRows, + type AppendPart, + type PartRef, + type SessionRow, +} from "./timeline" export function createSessionRows(sessionID: Accessor, options?: { readonly metrics?: Keyed.Metrics }) { const data = useData() const client = useClient() 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() + const state = SessionTimeline.make({ metrics }) const rows = { slots: useValue(state.slots), values: state.values, } const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + const isPending = (messageID: string) => { + const message = data.session.message.get(sessionID(), messageID) + if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID) + return message?.type === "compaction" && message.status === "running" + } + 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))) - }) + state.replace(value, isPending, pendingPermissions()) }) } - 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 }) - } + const mutate = (f: () => void) => batch(f) function reduce() { const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) - partitionPending(rows, pendingPermissions()) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( position === -1 ? rows.length : position, @@ -96,20 +65,7 @@ export function createSessionRows(sessionID: Accessor, options?: { reado createEffect(() => { const pending = pendingPermissions() - 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)), - }) - }) - }) + mutate(() => state.repartition(pending)) }) createEffect( @@ -162,6 +118,8 @@ export function createSessionRows(sessionID: Accessor, options?: { reado { id: message.id, created: message.time.created, + input: false, + status: message.status, }, ] : [], @@ -172,64 +130,16 @@ export function createSessionRows(sessionID: Accessor, options?: { reado const appendMessage = (messageID: string) => 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)) + state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" }) }) - const appendPart = (ref: PartRef, part: AppendPart) => - 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 appendPart = (ref: PartRef, part: AppendPart) => mutate(() => state.appendPart(ref, part)) - const appendFooter = (messageID: string) => - mutate(() => { - if (state.has(footerRowID(messageID))) return - const current = state.values() - const index = queuedStart(current) - complete(current, index) - insert(current, index, footerRow(messageID)) - }) + const appendFooter = (messageID: string) => mutate(() => state.appendFooter(messageID)) - const removeFooter = (messageID: string) => - mutate(() => { - state.remove(footerRowID(messageID)) - }) - - const isPending = (messageID: string) => { - const message = data.session.message.get(sessionID(), messageID) - if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID) - return message?.type === "compaction" && message.status === "running" - } - - const queuedStart = (rows: readonly SessionRow[]) => { - const index = rows.findIndex( - (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)), - ) - return index === -1 ? rows.length : index - } + const removeFooter = (messageID: string) => mutate(() => state.removeFooter(messageID)) const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) @@ -296,7 +206,7 @@ export function createSessionRows(sessionID: Accessor, options?: { reado if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID) }), data.on("session.step.ended", (event) => { - if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return + if (event.data.sessionID !== sessionID() || !isTerminalFinish(event.data.finish)) return appendFooter(event.data.assistantMessageID) }), data.on("session.step.failed", (event) => { @@ -310,182 +220,3 @@ export function createSessionRows(sessionID: Accessor, options?: { reado return rows } - -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { - const isInput = (message: SessionMessageInfo) => inputs.has(message.id) - const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") - const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) - return [ - ...messages.filter((message) => !pending.has(message.id)), - ...pendingCompactions, - ...messages.filter(isInput), - ].reduce((rows, message) => { - if (message.type !== "assistant") { - if (message.type === "synthetic" && !message.description?.trim()) return rows - if (!pending.has(message.id)) completePrevious(rows) - rows.push(messageRow(message.id)) - return rows - } - const ordinals = { text: 0, reasoning: 0 } - message.content.forEach((part) => { - const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` - if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID }, part) - }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { - completePrevious(rows) - rows.push(footerRow(message.id)) - } - return rows - }, []) -} - -type BoundaryRow = - | Pick, "type" | "messageID"> - | Pick, "type"> - | Pick, "type" | "ref"> - | Pick, "type" | "origin"> - | Pick, "type" | "messageID"> - -export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) { - const byID = new Map(messages.map((message) => [message.id, message])) - const seen = new Set() - return rows.map((row) => { - const id = rowBoundaryMessageID(row, byID) - if (!id || seen.has(id)) return undefined - seen.add(id) - return id - }) -} - -function rowBoundaryMessageID(row: BoundaryRow, messages: Map) { - if (row.type === "message") { - const message = messages.get(row.messageID) - if (message?.type === "user" && message.text.trim()) return message.id - return undefined - } - const messageID = - row.type === "part" - ? row.ref.messageID - : row.type === "group" - ? row.origin.messageID - : row.type === "assistant-footer" - ? row.messageID - : undefined - if (!messageID) return undefined - const message = messages.get(messageID) - if (message?.type === "assistant") return message.id -} - -export function resolvePart(message: SessionMessageAssistant, partID: string) { - const tool = message.content.find((part) => part.type === "tool" && part.id === partID) - if (tool) return tool - const match = /^(text|reasoning):(\d+)$/.exec(partID) - if (!match) return - const ordinal = Number(match[2]) - return message.content.filter((part) => part.type === match[1])[ordinal] -} - -type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string } - -function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) { - 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, 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) { - const previous = rows[index - 1] - if (previous?.type === "group") previous.completed = true -} - -function partitionPending(rows: SessionRow[], pending: Set) { - rows.forEach((row) => { - if (row.type !== "group" || row.kind !== "exploration") return - const refs = [...row.refs, ...row.pending] - row.refs = refs.filter((ref) => !pending.has(ref.partID)) - row.pending = refs.filter((ref) => pending.has(ref.partID)) - }) -} - -function exploration(name: string) { - return ["read", "glob", "grep"].includes(name.toLowerCase()) -} - -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 -} diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts new file mode 100644 index 000000000000..be59c6353dd6 --- /dev/null +++ b/packages/tui/src/routes/session/timeline.ts @@ -0,0 +1,328 @@ +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" +import { Keyed, Layout, Transaction } from "effect-quark" + +const PartRefLayout = Layout.struct({ + messageID: Layout.string, + partID: Layout.string, +}) + +const SessionRowLayout = Layout.keyedUnion({ + key: Layout.key("id", Layout.string), + tag: "type", + variants: { + message: Layout.struct({ messageID: Layout.string }), + "compaction-queued": Layout.struct({ inputID: Layout.string }), + part: Layout.struct({ ref: PartRefLayout }), + group: Layout.union({ + tag: "kind", + variants: { + reasoning: Layout.struct({ + origin: Layout.immutable(PartRefLayout), + refs: Layout.array(PartRefLayout), + completed: Layout.boolean, + }), + exploration: Layout.struct({ + origin: Layout.immutable(PartRefLayout), + refs: Layout.array(PartRefLayout), + pending: Layout.array(PartRefLayout), + completed: Layout.boolean, + }), + }, + }), + "assistant-footer": Layout.struct({ messageID: Layout.string }), + }, +}) + +export type PartRef = Layout.Type +export type SessionRow = Layout.Type +export type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string } + +const SessionRows = Layout.collection( + SessionRowLayout, + ({ members }) => ({ + parts: members((row) => { + if (row.type === "part") return [row.id] + if (row.type !== "group") return [] + if (row.kind === "reasoning") return row.refs.map(partRowID) + return [...row.refs, ...row.pending].map(partRowID) + }), + }), + { backend: "generated" }, +) + +export namespace SessionTimeline { + export function make(options?: { readonly metrics?: Keyed.Metrics }) { + const state = SessionRows.make([], options) + let activeGroupID: string | undefined + let queuedBoundaryID: string | undefined + const queuedRowIDs = new Set() + + const insert = (row: SessionRow) => state.insert(row, queuedBoundaryID ? { before: queuedBoundaryID } : "end") + + const complete = () => { + if (!activeGroupID) return + state.modify(activeGroupID, (row) => (row.type === "group" ? { ...row, completed: true } : row)) + activeGroupID = undefined + } + + const nextQueued = (key: string) => { + const row = state.after(key)?.() + return row && queuedRowIDs.has(row.id) ? row.id : undefined + } + + const replace = ( + rows: readonly SessionRow[], + isQueued: (messageID: string) => boolean, + pending: ReadonlySet, + ) => + Transaction.run(() => { + queuedBoundaryID = undefined + activeGroupID = undefined + queuedRowIDs.clear() + state.set( + rows.map((row) => { + const next = partition(row, pending) + const queued = next.type === "compaction-queued" || (next.type === "message" && isQueued(next.messageID)) + if (queued) { + queuedRowIDs.add(next.id) + queuedBoundaryID ??= next.id + return next + } + if (queuedBoundaryID) return next + activeGroupID = next.type === "group" && !next.completed ? next.id : undefined + return next + }), + ) + }) + + const appendMessage = (messageID: string, status: { readonly pending: boolean; readonly compaction: boolean }) => { + const id = messageRowID(messageID) + const exists = state.has(id) + if (exists && (status.pending || !queuedRowIDs.has(id))) return + Transaction.run(() => { + const row = messageRow(messageID) + if (status.pending) { + queuedRowIDs.add(row.id) + if (!status.compaction) { + state.insert(row, "end") + queuedBoundaryID ??= row.id + return + } + insert(row) + queuedBoundaryID = row.id + return + } + if (!exists) { + complete() + insert(row) + return + } + queuedRowIDs.delete(row.id) + complete() + if (queuedBoundaryID === row.id) { + queuedBoundaryID = nextQueued(row.id) + return + } + if (queuedBoundaryID) state.move(row.id, { before: queuedBoundaryID }) + }) + } + + const appendPart = (ref: PartRef, part: AppendPart) => { + const id = partRowID(ref) + if (state.hasMember("parts", id)) return + Transaction.run(() => { + const kind = groupKind(part) + const active = activeGroupID ? state.get(activeGroupID)?.() : undefined + if (kind && active?.type === "group" && active.kind === kind) { + state.modify(active.id, (row) => (row.type === "group" ? { ...row, refs: [...row.refs, ref] } : row), { + members: { parts: { add: [id] } }, + }) + return + } + complete() + const row = kind ? groupRow(kind, ref) : partRow(ref) + insert(row) + activeGroupID = row.type === "group" ? row.id : undefined + }) + } + + const appendFooter = (messageID: string) => { + const id = footerRowID(messageID) + if (state.has(id)) return + Transaction.run(() => { + const row = footerRow(messageID) + complete() + insert(row) + }) + } + + const removeFooter = (messageID: string) => state.remove(footerRowID(messageID)) + + const repartition = (pending: ReadonlySet) => + Transaction.run(() => { + state.values().forEach((row) => { + if (row.type !== "group" || row.kind !== "exploration") return + const next = partition(row, pending) + if (next !== row) state.modify(row.id, () => next) + }) + }) + + return { + slots: state.slots, + values: state.values, + replace, + appendMessage, + appendPart, + appendFooter, + removeFooter, + repartition, + } + } +} + +export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { + const isInput = (message: SessionMessageInfo) => inputs.has(message.id) + const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") + const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) + return [ + ...messages.filter((message) => !pending.has(message.id)), + ...pendingCompactions, + ...messages.filter(isInput), + ].reduce((rows, message) => { + if (message.type !== "assistant") { + if (message.type === "synthetic" && !message.description?.trim()) return rows + if (!pending.has(message.id)) completePrevious(rows) + rows.push(messageRow(message.id)) + return rows + } + const ordinals = { text: 0, reasoning: 0 } + message.content.forEach((part) => { + const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` + if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return + append(rows, { messageID: message.id, partID }, part) + }) + if (isTerminalFinish(message.finish) || message.error || message.retry) { + completePrevious(rows) + rows.push(footerRow(message.id)) + } + return rows + }, []) +} + +export function messageBoundaryIDs(rows: readonly SessionRow[], messages: SessionMessageInfo[]) { + const byID = new Map(messages.map((message) => [message.id, message])) + const seen = new Set() + return rows.map((row) => { + const id = rowBoundaryMessageID(row, byID) + if (!id || seen.has(id)) return undefined + seen.add(id) + return id + }) +} + +function rowBoundaryMessageID(row: SessionRow, messages: Map) { + if (row.type === "message") { + const message = messages.get(row.messageID) + if (message?.type === "user" && message.text.trim()) return message.id + return undefined + } + const messageID = + row.type === "part" + ? row.ref.messageID + : row.type === "group" + ? row.origin.messageID + : row.type === "assistant-footer" + ? row.messageID + : undefined + if (!messageID) return undefined + const message = messages.get(messageID) + if (message?.type === "assistant") return message.id +} + +export function resolvePart(message: SessionMessageAssistant, partID: string) { + const tool = message.content.find((part) => part.type === "tool" && part.id === partID) + if (tool) return tool + const match = /^(text|reasoning):(\d+)$/.exec(partID) + if (!match) return + const ordinal = Number(match[2]) + return message.content.filter((part) => part.type === match[1])[ordinal] +} + +export function isTerminalFinish(finish: string | undefined) { + return !!finish && !["tool-calls", "unknown"].includes(finish) +} + +function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) { + const previous = rows[index - 1] + const kind = groupKind(part) + if (kind && previous?.type === "group" && previous.kind === kind) { + rows[index - 1] = { ...previous, refs: [...previous.refs, ref] } + return + } + completePrevious(rows, index) + rows.splice(index, 0, 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) { + const previous = rows[index - 1] + if (previous?.type === "group") rows[index - 1] = { ...previous, completed: true } +} + +function partition(row: SessionRow, pending: ReadonlySet): SessionRow { + if (row.type !== "group" || row.kind !== "exploration") return row + const changed = row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID)) + if (!changed) return row + const refs = [...row.refs, ...row.pending] + return { + ...row, + refs: refs.filter((ref) => !pending.has(ref.partID)), + pending: refs.filter((ref) => pending.has(ref.partID)), + } +} + +function exploration(name: string) { + return ["read", "glob", "grep"].includes(name.toLowerCase()) +} + +function messageRow(messageID: string): SessionRow { + return { id: messageRowID(messageID), type: "message", messageID } +} + +function messageRowID(messageID: string) { + return `m${segment(messageID)}` +} + +export 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}` +} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 1587944a20a7..fd39b0f744d0 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -8,7 +8,8 @@ import { createEffect, onMount, type ParentProps } from "solid-js" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" -import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" +import { createSessionRows } from "../../../src/routes/session/rows" +import type { SessionRow } from "../../../src/routes/session/timeline" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c69b027a197c..f71ee4929749 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,6 +1,11 @@ import { expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows" +import { + SessionTimeline, + messageBoundaryIDs, + reduceSessionRows, + type SessionRow, +} from "../../../src/routes/session/timeline" const withoutIDs = (rows: ReturnType) => rows.map(({ id: _id, ...row }) => { @@ -317,6 +322,162 @@ test("places a running compaction barrier before every queued user message", () ]) }) +test("matches snapshot reduction through direct timeline operations", () => { + const timeline = SessionTimeline.make() + const response = assistant("assistant-1", [ + { type: "reasoning", text: "First" }, + { type: "reasoning", text: "Second" }, + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + { type: "text", text: "Done" }, + ]) + response.finish = "stop" + const messages: SessionMessageInfo[] = [ + { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, + response, + { type: "user", id: "user-queued", text: "Continue", time: { created: 4 } }, + ] + + timeline.appendMessage("user-1", { pending: false, compaction: false }) + timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" }) + timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:1" }, { type: "reasoning" }) + timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" }) + timeline.appendPart({ messageID: "assistant-1", partID: "grep-1" }, { type: "tool", name: "grep" }) + timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" }) + timeline.appendFooter("assistant-1") + timeline.appendMessage("user-queued", { pending: true, compaction: false }) + + expect(timeline.values()).toEqual(reduceSessionRows(messages, new Set(["user-queued"]))) +}) + +test("ignores a duplicate part through the parts membership index", () => { + const timeline = SessionTimeline.make() + const ref = { messageID: "assistant-1", partID: "read-1" } + timeline.appendPart(ref, { type: "tool", name: "read" }) + const values = timeline.values() + const slots = timeline.slots() + + timeline.appendPart(ref, { type: "text" }) + + expect(timeline.values()).toBe(values) + expect(timeline.slots()).toBe(slots) +}) + +test("inserts output before the earliest queued compaction and prompt", () => { + const timeline = SessionTimeline.make() + timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" }) + timeline.appendMessage("user-1", { pending: true, compaction: false }) + timeline.appendMessage("user-2", { pending: true, compaction: false }) + timeline.appendMessage("compaction-1", { pending: true, compaction: true }) + timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" }) + + expect(withoutIDs([...timeline.values()])).toEqual([ + { + type: "group", + kind: "exploration", + pending: [], + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, + { type: "message", messageID: "compaction-1" }, + { type: "message", messageID: "user-1" }, + { type: "message", messageID: "user-2" }, + ]) +}) + +test("keeps a group slot stable through join, repartition, and completion", () => { + const timeline = SessionTimeline.make() + timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" }) + const slot = timeline.slots()[0] + + timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" }) + expect(timeline.slots()[0]).toBe(slot) + + timeline.repartition(new Set(["read-1"])) + expect(timeline.slots()[0]).toBe(slot) + expect(slot()).toMatchObject({ + refs: [{ messageID: "assistant-2", partID: "grep-1" }], + pending: [{ messageID: "assistant-1", partID: "read-1" }], + completed: false, + }) + + timeline.appendMessage("user-queued", { pending: true, compaction: false }) + expect(slot()).toMatchObject({ completed: false }) + timeline.appendMessage("user-queued", { pending: false, compaction: false }) + + expect(timeline.slots()[0]).toBe(slot) + expect(slot()).toMatchObject({ completed: true }) +}) + +test("does not complete an active group for duplicate messages or footers", () => { + const timeline = SessionTimeline.make() + timeline.appendMessage("user-1", { pending: false, compaction: false }) + timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" }) + const first = timeline.slots()[1] + + timeline.appendMessage("user-1", { pending: false, compaction: false }) + expect(first()).toMatchObject({ completed: false }) + + timeline.appendFooter("assistant-1") + timeline.appendPart({ messageID: "assistant-2", partID: "reasoning:0" }, { type: "reasoning" }) + const second = timeline.slots()[3] + timeline.appendFooter("assistant-1") + + expect(second()).toMatchObject({ completed: false }) +}) + +test("moves a promoted queued message before the remaining queue", () => { + const timeline = SessionTimeline.make() + timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" }) + timeline.appendMessage("user-1", { pending: true, compaction: false }) + timeline.appendMessage("user-2", { pending: true, compaction: false }) + + timeline.appendMessage("user-2", { pending: false, compaction: false }) + timeline.appendPart({ messageID: "assistant-2", partID: "text:0" }, { type: "text" }) + + expect(withoutIDs([...timeline.values()])).toEqual([ + { + type: "group", + kind: "reasoning", + completed: true, + refs: [{ messageID: "assistant-1", partID: "reasoning:0" }], + }, + { type: "message", messageID: "user-2" }, + { type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, + { type: "message", messageID: "user-1" }, + ]) +}) + +test("advances the queue boundary when a running compaction completes", () => { + const timeline = SessionTimeline.make() + timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" }) + timeline.appendMessage("compaction-1", { pending: true, compaction: true }) + timeline.appendMessage("user-1", { pending: true, compaction: false }) + + timeline.appendMessage("compaction-1", { pending: false, compaction: true }) + timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" }) + + expect(withoutIDs([...timeline.values()])).toEqual([ + { + type: "group", + kind: "exploration", + pending: [], + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "message", messageID: "compaction-1" }, + { + type: "group", + kind: "exploration", + pending: [], + completed: false, + refs: [{ messageID: "assistant-2", partID: "grep-1" }], + }, + { type: "message", messageID: "user-1" }, + ]) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant", From 6548844678e5aa2df538f69f9010bba2adacbdbe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 22:15:46 -0400 Subject: [PATCH 03/18] chore(quark): sync vendored quark with diff gating and codegen --- packages/quark/src/keyed.ts | 46 +- packages/quark/src/layout.ts | 892 +++++++++++++++++++----------- packages/quark/src/reactivity.ts | 9 +- packages/quark/test/keyed.test.ts | 4 +- 4 files changed, 617 insertions(+), 334 deletions(-) diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts index 6bb970aafd05..7468ef72dabe 100644 --- a/packages/quark/src/keyed.ts +++ b/packages/quark/src/keyed.ts @@ -61,9 +61,12 @@ export namespace Keyed { if (publish(slot, value)) changed = true return slot }) - byKey.forEach((_slot, key) => { - if (!retained.has(key)) byKey.delete(key) - }) + // After reconciliation byKey is a superset of retained; equal sizes + // mean no stale keys and the sweep can be skipped. + if (byKey.size !== retained.size) + byKey.forEach((_slot, key) => { + if (!retained.has(key)) byKey.delete(key) + }) if (!same(previous, reconciled)) { slots.set(reconciled) if (options.metrics) options.metrics.structuralPublications++ @@ -73,14 +76,13 @@ export namespace Keyed { }) }, update(value) { - const key = options.key(value) - const slot = byKey.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + const slot = byKey.get(options.key(value)) + if (!slot) return false return publish(slot, value) }, modify(key, f) { const slot = byKey.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + if (!slot) return false const value = f(slot()) if (byKey.get(options.key(value)) !== slot) throw new Error("Keyed modify must preserve the value key") return publish(slot, value) @@ -91,32 +93,33 @@ export namespace Keyed { 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++ - }) + // byKey is not reactive and slots.set is a single publication, so no + // transaction is required here; callers batch when they need to. + 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++ - }) + 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)}`) + if (!slot) return false 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)) + const next = current.slice() + next.splice(from, 1) + next.splice(to, 0, slot) + slots.set(next) if (options.metrics) options.metrics.structuralPublications++ return true }, @@ -129,7 +132,8 @@ export namespace Keyed { } function publish(slot: Writable, value: A) { - if (equivalent(slot(), value)) { + const current = slot() + if (current === value || equivalent(current, value)) { if (options.metrics) options.metrics.equivalenceSuppressions++ return false } @@ -152,7 +156,7 @@ export namespace Keyed { function neighbor(key: Key, offset: -1 | 1) { const slot = byKey.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + if (!slot) return undefined const current = slots() return current[current.indexOf(slot) + offset] } diff --git a/packages/quark/src/layout.ts b/packages/quark/src/layout.ts index f30772d7fa49..adb6fa1964c8 100644 --- a/packages/quark/src/layout.ts +++ b/packages/quark/src/layout.ts @@ -21,12 +21,16 @@ export namespace Layout { export type Type = Field extends Layout.Field ? A : never type Fields = Readonly>> - type Value = { readonly [Key in keyof StructFields]: Type } + type Value = { + readonly [Key in keyof StructFields]: Type + } type KeyName = { readonly [Key in keyof StructFields]: StructFields[Key] extends KeyField ? Key : never }[keyof StructFields] type Variant = { - readonly [Name in keyof Variants & string]: { readonly [Key in Tag]: Name } & Type + readonly [Name in keyof Variants & string]: { + readonly [Key in Tag]: Name + } & Type }[keyof Variants & string] type KeyedVariant = { readonly [Key in Name]: A @@ -60,6 +64,10 @@ export namespace Layout { readonly key: PropertyKey readonly fields?: Fields readonly equivalent: (left: A, right: A) => boolean + /** Bitmask of changed top-level fields; 0 means equivalent. Consistent with `equivalent`. */ + readonly diff: (left: A, right: A) => number + /** Every known top-level property name to its change bit; immutable, key, and tag names map to 0. */ + readonly bits: ReadonlyMap readonly keyOf: (value: A) => Key make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed } @@ -77,12 +85,24 @@ export namespace Layout { export type Index = MembersIndex | FirstIndex type Indexes = Readonly>> type MembersNames = { - readonly [Name in keyof Definitions]: Definitions[Name] extends { readonly type: "members" } ? Name : never + readonly [Name in keyof Definitions]: Definitions[Name] extends { + readonly type: "members" + } + ? Name + : never }[keyof Definitions] type FirstNames = { - readonly [Name in keyof Definitions]: Definitions[Name] extends { readonly type: "first" } ? Name : never + readonly [Name in keyof Definitions]: Definitions[Name] extends { + readonly type: "first" + } + ? Name + : never }[keyof Definitions] - type Member = Definition extends { readonly extract: (value: never) => Iterable } ? A : never + type Member = Definition extends { + readonly extract: (value: never) => Iterable + } + ? A + : never type MemberChanges = { readonly [Name in MembersNames]?: { readonly add?: readonly Member[] @@ -90,6 +110,15 @@ export namespace Layout { } } + /** + * Comparator backend. "generated" (the default) compiles one specialized + * Function per plan; "closure" is the compatibility fallback for + * environments that forbid runtime code generation (CSP). + */ + export interface CompileOptions { + readonly backend?: "closure" | "generated" + } + export interface IndexBuilder { members(extract: (value: A) => Iterable): MembersIndex first(matches: (value: A) => boolean): FirstIndex @@ -109,14 +138,25 @@ export namespace Layout { export const number: Field = primitive() export const boolean: Field = primitive() + interface ArrayField extends Field { + readonly type: "array" + readonly item: Field + } + export function array(item: Field): Field { - return make((left, right) => { - if (left.length !== right.length) return false - for (let index = 0; index < left.length; index++) { - if (!item.equivalent(left[index], right[index])) return false - } - return true - }) + const field: ArrayField = { + type: "array", + item, + equivalent(left, right) { + if (left === right) return true + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index++) { + if (!item.equivalent(left[index], right[index])) return false + } + return true + }, + } + return field } export function immutable(field: Field): Field { @@ -165,28 +205,30 @@ export namespace Layout { export function compile( layout: Struct, - options?: { readonly backend?: "closure" | "generated" }, + options?: CompileOptions, ): Plan, Value[KeyName]> export function compile< const Name extends PropertyKey, A, const Tag extends PropertyKey, const Variants extends Readonly>>, - >( - layout: KeyedUnion, - options?: { readonly backend?: "closure" | "generated" }, - ): Plan, A> - export function compile(input: unknown, options: { readonly backend?: "closure" | "generated" } = {}): unknown { + >(layout: KeyedUnion, options?: CompileOptions): Plan, A> + export function compile(input: unknown, options: CompileOptions = {}): unknown { const layout = input as | Struct | KeyedUnion>>> if (layout.type === "keyed-union") { + const model = unionDiffModel(layout.key.name, layout.tag, layout.variants) return makePlan( layout.key.name, (value: unknown) => (value as Record)[layout.key.name], - (options.backend === "generated" + (options.backend !== "closure" ? generateUnion(layout.tag, layout.variants) : compileUnion(layout.tag, layout.variants)) as (left: unknown, right: unknown) => boolean, + options.backend !== "closure" + ? generateUnionDiff(layout.tag, layout.variants, model) + : compileUnionDiff(layout.tag, layout.variants, model), + model.bits, ) } @@ -195,21 +237,23 @@ export namespace Layout { const key = keys[0] const fields = Reflect.ownKeys(layout.fields) .filter((name) => name !== key && !layout.fields[name].immutable) - .map((name) => ({ name, field: layout.fields[name] })) + .map((name, index) => ({ name, field: layout.fields[name], bit: 1 << Math.min(index, 30) })) + const bits = new Map() + Reflect.ownKeys(layout.fields).forEach((name) => bits.set(name, 0)) + fields.forEach((field) => bits.set(field.name, field.bit)) const equivalent = - options.backend === "generated" - ? generateEquivalent(generated(fields)) - : compileEquivalent(fields) + options.backend !== "closure" ? generateEquivalent(fields) : compileEquivalent(fields) + const diff = options.backend !== "closure" ? generateDiff(fields) : compileDiff(fields) return { fields: layout.fields, - ...makePlan(key, (value: unknown) => (value as Record)[key], equivalent), + ...makePlan(key, (value: unknown) => (value as Record)[key], equivalent, diff, bits), } } export function collection>>( layout: Struct, define: (index: IndexBuilder>) => Definitions, - options?: { readonly backend?: "closure" | "generated" }, + options?: CompileOptions, ): CollectionPlan, Value[KeyName], Definitions> export function collection< const Name extends PropertyKey, @@ -220,13 +264,9 @@ export namespace Layout { >( layout: KeyedUnion, define: (index: IndexBuilder>) => Definitions, - options?: { readonly backend?: "closure" | "generated" }, + options?: CompileOptions, ): CollectionPlan, A, Definitions> - export function collection( - input: unknown, - define: unknown, - options?: { readonly backend?: "closure" | "generated" }, - ): unknown { + export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown { const plan = compile(input as never, options) as Plan const definitions = (define as (index: IndexBuilder) => Indexes)({ members: (extract) => ({ type: "members", extract }), @@ -240,13 +280,25 @@ export namespace Layout { } } - function makePlan(key: PropertyKey, getKey: (value: A) => Key, equivalent: (left: A, right: A) => boolean) { + function makePlan( + key: PropertyKey, + getKey: (value: A) => Key, + equivalent: (left: A, right: A) => boolean, + diff: (left: A, right: A) => number, + bits: ReadonlyMap, + ) { return { key, equivalent, + diff, + bits, keyOf: getKey, make(initial: readonly A[] = [], options?: { readonly metrics?: Keyed.Metrics }) { - const values = Keyed.make({ key: getKey, equivalent, metrics: options?.metrics }) + const values = Keyed.make({ + key: getKey, + equivalent, + metrics: options?.metrics, + }) values.set(initial) return values }, @@ -259,99 +311,300 @@ export namespace Layout { initial: readonly A[], options?: { readonly metrics?: Keyed.Metrics }, ): Collection { - const values = plan.make([], options) - type MemberEntry = { - readonly type: "members" - readonly name: PropertyKey - readonly extract: (value: A) => Iterable - readonly counts: Map - readonly byKey: Map; readonly members: Set }> + // The most recent publication's field diff, captured from the equivalence + // callback so index refresh can gate projections on it. `pending` hands a + // diff the collection already computed for a staged mutation to the + // comparator, so equivalence costs one comparison, not two. + let lastDiff = -1 + let pending = -1 + const values = Keyed.make({ + key: plan.keyOf, + equivalent(left, right) { + lastDiff = pending !== -1 ? pending : plan.diff(left, right) + return lastDiff === 0 + }, + metrics: options?.metrics, + }) + + // Projection dependency tracker: extract/matches run against a proxy that + // records which top-level fields they read, as a bitmask aligned with + // plan.diff. Enumerating keys or reading an unknown property is + // conservative (all bits). Soundness: a pure projection re-reads a field + // only if the fields it previously read are unchanged or it is re-run. + let trackTarget: Record | undefined + let trackMask = 0 + const trackProxy = new Proxy({} as Record, { + get(_, property) { + trackMask |= plan.bits.get(property) ?? -1 + return trackTarget![property] + }, + has(_, property) { + trackMask |= plan.bits.get(property) ?? -1 + return property in trackTarget! + }, + ownKeys() { + trackMask = -1 + return Reflect.ownKeys(trackTarget!) + }, + getOwnPropertyDescriptor(_, property) { + trackMask = -1 + return Reflect.getOwnPropertyDescriptor(trackTarget!, property) + }, + }) + function tracked(value: A, f: (value: A) => T) { + trackTarget = value as Record + trackMask = 0 + const result = f(trackProxy as A) + trackTarget = undefined + return result } - type FirstEntry = { - readonly type: "first" + + // Each index entry stages user-code projections before any state mutation + // (a throwing callback must not desynchronize values and indexes) and + // returns a commit applied after publication succeeds. + type Commit = (slot: Readable) => void + type MemberChange = { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] } + interface Entry { readonly name: PropertyKey - readonly matches: (value: A) => boolean - readonly matching: Set - slot?: Readable + reads(key: Key): number | undefined + stage(key: Key, value: A, mode: "add" | "replace"): Commit + applyDelta?(key: Key, change: MemberChange): void + remove(key: Key, slot?: Readable): void + afterPlacement(slot: Readable): void + afterRebuild(): void + member?(candidate: unknown): boolean + firstSlot?(): Readable | undefined + } + + function membersEntry(name: PropertyKey, extract: (value: A) => Iterable): Entry { + const counts = new Map() + const byKey = new Map; readonly members: Set }>() + const readsByKey = new Map() + + function adjust(member: unknown, amount: 1 | -1) { + const count = (counts.get(member) ?? 0) + amount + if (count === 0) { + counts.delete(member) + return + } + counts.set(member, count) + } + + return { + name, + reads: (key) => readsByKey.get(key), + stage(key, value) { + const source = tracked(value, extract) + const reads = trackMask + const previous = byKey.get(key) + const members = source === previous?.source ? previous.members : new Set(source) + return () => { + readsByKey.set(key, reads) + if (!previous) members.forEach((member) => adjust(member, 1)) + if (previous && previous.members !== members) { + members.forEach((member) => !previous.members.has(member) && adjust(member, 1)) + previous.members.forEach((member) => !members.has(member) && adjust(member, -1)) + } + byKey.set(key, { source, members }) + } + }, + applyDelta(key, change) { + const members = byKey.get(key)!.members + change.remove?.forEach((member) => { + if (members.delete(member)) adjust(member, -1) + }) + change.add?.forEach((member) => { + if (members.has(member)) return + members.add(member) + adjust(member, 1) + }) + // The set was mutated in place: poison the source-identity check and + // drop observed reads so the next non-delta projection recomputes. + byKey.set(key, { source: members, members }) + readsByKey.delete(key) + }, + remove(key) { + byKey.get(key)?.members.forEach((member) => adjust(member, -1)) + byKey.delete(key) + readsByKey.delete(key) + }, + afterPlacement() {}, + afterRebuild() {}, + member: (candidate) => counts.has(candidate), + } } - type Entry = MemberEntry | FirstEntry - type Inspection = - | { - readonly type: "members" - readonly value: { readonly source: Iterable; readonly members: Set } + + function firstEntry(name: PropertyKey, matches: (value: A) => boolean): Entry { + const matching = new Set() + const readsByKey = new Map() + let slot: Readable | undefined + + function findFirst() { + slot = values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))) + } + + function afterPlacement(candidate: Readable) { + if (!matching.has(plan.keyOf(candidate()))) return + if (!slot) { + slot = candidate + return + } + if (slot === candidate) { + findFirst() + return + } + // Single pass: whichever of the two slots appears first wins. + for (const current of values.slots()) { + if (current === candidate) { + slot = candidate + return + } + if (current === slot) return } - | { readonly type: "members-change"; readonly add: readonly unknown[]; readonly remove: readonly unknown[] } - | { readonly type: "first"; readonly value: boolean } + } + + return { + name, + reads: (key) => readsByKey.get(key), + stage(key, value, mode) { + const matched = tracked(value, matches) + const reads = trackMask + return (valueSlot) => { + readsByKey.set(key, reads) + const previous = matching.has(key) + if (matched) matching.add(key) + if (!matched) matching.delete(key) + if (mode === "add") return + if (slot === valueSlot && !matched) { + findFirst() + return + } + if (slot !== valueSlot && !previous && matched) afterPlacement(valueSlot) + } + }, + remove(key, removedSlot) { + matching.delete(key) + readsByKey.delete(key) + if (removedSlot && slot === removedSlot) findFirst() + }, + afterPlacement, + afterRebuild: findFirst, + firstSlot: () => slot, + } + } + const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => { const definition = definitions[name] - if (definition.type === "members") { - return { type: "members", name, extract: definition.extract, counts: new Map(), byKey: new Map() } - } - return { type: "first", name, matches: definition.matches, matching: new Set() } + if (definition.type === "members") return membersEntry(name, definition.extract) + return firstEntry(name, definition.matches) }) const byName = new Map(entries.map((entry) => [entry.name, entry])) - const emptyMembers: readonly unknown[] = [] + + // Stage refresh work for one changed value: explicit member deltas win, + // then projections whose observed reads are disjoint from the change are + // skipped entirely. Returns undefined when no index work is required. + function stageRefresh(key: Key, value: A, mask: number, changes?: unknown) { + const record = changes as Readonly> | undefined + let staged: Commit[] | undefined + for (const entry of entries) { + const change = record?.[entry.name] + if (change && entry.applyDelta) { + const delta = entry.applyDelta + ;(staged ??= []).push(() => delta(key, change)) + continue + } + const reads = entry.reads(key) + if (reads !== undefined && (mask & reads) === 0) continue + ;(staged ??= []).push(entry.stage(key, value, "replace")) + } + return staged + } + + function publishStaged(value: A, mask: number, staged: readonly Commit[] | undefined, slot: Readable) { + // Indexes are plain data, not reactive state; the transaction exists so + // subscribers cannot observe published values with stale indexes. With + // no staged commits there is nothing to observe out of order. + if (!staged) { + pending = mask + const changed = values.update(value) + pending = -1 + return changed + } + return Transaction.run(() => { + pending = mask + const changed = values.update(value) + pending = -1 + if (changed) staged.forEach((commit) => commit(slot)) + return changed + }) + } const collection: Collection = { ...values, set(next) { const keys = next.map(plan.keyOf) - if (new Set(keys).size !== keys.length) return values.set(next) - const prepared = new Map( - next.map((value, index) => { - const key = keys[index] - const previous = values.get(key)?.() - return [key, previous && plan.equivalent(previous, value) ? current(key) : inspect(value, key)] - }), - ) + if (new Set(keys).size !== keys.length) throw new Error("Keyed values must have unique keys") + const existing = new Map() + values.slots().forEach((slot) => { + const value = slot() + existing.set(plan.keyOf(value), value) + }) + // Stage user-code projections for new and changed values only; + // retained equivalent values keep their index state and reads. + const staged: Array<{ readonly key: Key; readonly commits: readonly Commit[] }> = [] + next.forEach((value, index) => { + const key = keys[index] + if (!existing.has(key)) { + staged.push({ key, commits: entries.map((entry) => entry.stage(key, value, "add")) }) + return + } + const mask = plan.diff(existing.get(key)!, value) + if (mask === 0) return + const commits = stageRefresh(key, value, mask) + if (commits) staged.push({ key, commits }) + }) + const retained = new Set(keys) return Transaction.run(() => { const changed = values.set(next) - if (!changed) return false - entries.forEach(clear) - values.slots().forEach((slot) => { - const key = plan.keyOf(slot()) - const inspection = prepared.get(key)! - entries.forEach((entry, index) => add(entry, key, slot, inspection[index])) + existing.forEach((_value, key) => { + if (!retained.has(key)) entries.forEach((entry) => entry.remove(key)) }) - entries.forEach((entry) => entry.type === "first" && findFirst(entry)) - return true + staged.forEach((item) => { + const slot = values.get(item.key)! + item.commits.forEach((commit) => commit(slot)) + }) + entries.forEach((entry) => entry.afterRebuild()) + return changed }) }, update(value) { const key = plan.keyOf(value) const slot = values.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) - const inspection = inspect(value, key) - return Transaction.run(() => { - const changed = values.update(value) - if (changed) entries.forEach((entry, index) => replace(entry, key, slot, inspection[index])) - return changed - }) + if (!slot) return false + const mask = plan.diff(slot(), value) + const staged = mask === 0 ? undefined : stageRefresh(key, value, mask) + return publishStaged(value, mask, staged, slot) }, modify(key, f, changes) { const slot = values.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) - let inspection: readonly Inspection[] | undefined - return Transaction.run(() => { - const changed = values.modify(key, (previous) => { - const value = f(previous) - if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key") - inspection = inspect(value, key, changes?.members) - return value - }) - if (changed) entries.forEach((entry, index) => replace(entry, key, slot, inspection![index])) - return changed - }) + if (!slot) return false + const value = f(slot()) + // Publication below targets the slot for the value's own key, so the + // modify key-preservation invariant must be enforced here. + if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key") + const mask = plan.diff(slot(), value) + const staged = mask === 0 ? undefined : stageRefresh(key, value, mask, changes?.members) + return publishStaged(value, mask, staged, slot) }, insert(value, position) { const key = plan.keyOf(value) - if (values.has(key)) return values.insert(value, position) + if (values.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`) requirePosition(position) - const inspection = inspect(value, key) + const staged = entries.map((entry) => entry.stage(key, value, "add")) return Transaction.run(() => { const slot = values.insert(value, position) - entries.forEach((entry, index) => add(entry, key, slot, inspection[index])) - entries.forEach((entry) => entry.type === "first" && updateFirstAfterPlacement(entry, slot)) + staged.forEach((commit) => commit(slot)) + entries.forEach((entry) => entry.afterPlacement(slot)) return slot }) }, @@ -360,144 +613,29 @@ export namespace Layout { if (!slot) return false return Transaction.run(() => { const removed = values.remove(key) - entries.forEach((entry) => remove(entry, key)) - entries.forEach((entry) => entry.type === "first" && entry.slot === slot && findFirst(entry)) + entries.forEach((entry) => entry.remove(key, slot)) return removed }) }, move(key, position) { const slot = values.get(key) - if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + if (!slot) return false return Transaction.run(() => { const moved = values.move(key, position) - if (moved) entries.forEach((entry) => entry.type === "first" && updateFirstAfterPlacement(entry, slot)) + if (moved) entries.forEach((entry) => entry.afterPlacement(slot)) return moved }) }, hasMember(name, member) { - const entry = byName.get(normalizeName(name)) - return entry?.type === "members" && entry.counts.has(member) + return byName.get(normalizeName(name))?.member?.(member) ?? false }, first(name) { - const entry = byName.get(normalizeName(name)) - return entry?.type === "first" ? entry.slot : undefined + return byName.get(normalizeName(name))?.firstSlot?.() }, } collection.set(initial) return collection - function inspect(value: A, key: Key, changes?: Readonly>): readonly Inspection[] { - return entries.map((entry) => - entry.type === "members" - ? (() => { - const change = changes?.[entry.name] as - | { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] } - | undefined - if (change) - return { - type: "members-change" as const, - add: change.add ?? emptyMembers, - remove: change.remove ?? emptyMembers, - } - const source = entry.extract(value) - const previous = entry.byKey.get(key) - return { - type: "members" as const, - value: { source, members: source === previous?.source ? previous.members : new Set(source) }, - } - })() - : { type: "first", value: entry.matches(value) }, - ) - } - - function current(key: Key): readonly Inspection[] { - return entries.map((entry) => { - if (entry.type === "members") return { type: "members", value: entry.byKey.get(key)! } - return { type: "first", value: entry.matching.has(key) } - }) - } - - function clear(entry: Entry) { - if (entry.type === "members") entry.byKey.clear() - if (entry.type === "first") entry.matching.clear() - if (entry.type === "members") entry.counts.clear() - if (entry.type === "first") entry.slot = undefined - } - - function add(entry: Entry, key: Key, slot: Readable, inspection: Inspection) { - if (entry.type === "members" && inspection.type === "members") { - entry.byKey.set(key, inspection.value) - inspection.value.members.forEach((member) => entry.counts.set(member, (entry.counts.get(member) ?? 0) + 1)) - return - } - if (entry.type === "first" && inspection.type === "first" && inspection.value) entry.matching.add(key) - } - - function remove(entry: Entry, key: Key) { - if (entry.type === "first") { - entry.matching.delete(key) - return - } - entry.byKey.get(key)?.members.forEach((member) => adjust(entry.counts, member, -1)) - entry.byKey.delete(key) - } - - function replace(entry: Entry, key: Key, slot: Readable, inspection: Inspection) { - if (entry.type === "members" && inspection.type === "members-change") { - const current = entry.byKey.get(key)! - const members = current.members - inspection.remove.forEach((member) => { - if (!members.delete(member)) return - adjust(entry.counts, member, -1) - }) - inspection.add.forEach((member) => { - if (members.has(member)) return - members.add(member) - adjust(entry.counts, member, 1) - }) - entry.byKey.set(key, { source: members, members }) - return - } - if (entry.type === "members" && inspection.type === "members") { - const previous = entry.byKey.get(key)!.members - if (previous === inspection.value.members) { - entry.byKey.set(key, inspection.value) - return - } - inspection.value.members.forEach((member) => !previous.has(member) && adjust(entry.counts, member, 1)) - previous.forEach((member) => !inspection.value.members.has(member) && adjust(entry.counts, member, -1)) - entry.byKey.set(key, inspection.value) - return - } - if (entry.type !== "first" || inspection.type !== "first") return - const previous = entry.matching.has(key) - if (inspection.value) entry.matching.add(key) - if (!inspection.value) entry.matching.delete(key) - if (entry.slot === slot && !inspection.value) findFirst(entry) - if (entry.slot !== slot && !previous && inspection.value) updateFirstAfterPlacement(entry, slot) - } - - function adjust(counts: Map, member: unknown, amount: 1 | -1) { - const count = (counts.get(member) ?? 0) + amount - if (count === 0) counts.delete(member) - if (count > 0) counts.set(member, count) - } - - function updateFirstAfterPlacement(entry: FirstEntry, slot: Readable) { - if (!entry.matching.has(plan.keyOf(slot()))) return - if (!entry.slot) { - entry.slot = slot - return - } - if (entry.slot === slot) return findFirst(entry) - const slots = values.slots() - if (slots.indexOf(slot) < slots.indexOf(entry.slot)) entry.slot = slot - } - - function findFirst(entry: FirstEntry) { - entry.slot = values.slots().find((slot) => entry.matching.has(plan.keyOf(slot()))) - } - function requirePosition(position?: Keyed.Position) { if (!position || position === "end") return const key = "before" in position ? position.before : position.after @@ -509,10 +647,6 @@ export namespace Layout { } } - function make(equivalent: (left: A, right: A) => boolean): Field { - return { equivalent } - } - function primitive(): Field { return { primitive: true, equivalent: Object.is } } @@ -538,21 +672,154 @@ export namespace Layout { ) } + // Field diff: a bitmask of changed top-level fields, 0 meaning equivalent. + // Bits are assigned per mutable field name; names beyond 31 share the top + // bit conservatively. The diff is consistent with `equivalent` by + // construction: both compare the same fields with the same field comparators. + type DiffField = { readonly name: PropertyKey; readonly field: Field; readonly bit: number } + + function compileDiff(fields: ReadonlyArray) { + return (left: A, right: A) => { + const a = left as Record + const b = right as Record + let changed = 0 + for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit + return changed + } + } + + function generateDiff(fields: ReadonlyArray) { + if (fields.some((field) => typeof field.name === "symbol")) return compileDiff(fields) + const emitter: Emitter = { custom: [], declarations: [], names: new Map() } + const statements = fields.map((field) => { + const property = JSON.stringify(String(field.name)) + return `if (!(${expression(emitter, field.field, `left[${property}]`, `right[${property}]`)})) changed |= ${field.bit}` + }) + const factory = Function( + "custom", + `${emitter.declarations.join("\n")}\nreturn (left, right) => { let changed = 0\n${statements.join("\n")}\nreturn changed }`, + ) as (custom: ReadonlyArray["equivalent"]>) => (left: A, right: A) => number + return factory(emitter.custom) + } + + type UnionDiffModel = { + readonly bits: ReadonlyMap + readonly perVariant: ReadonlyMap | undefined> + } + + function unionDiffModel( + keyName: PropertyKey, + tag: PropertyKey, + variants: Readonly>>, + ): UnionDiffModel { + const bits = new Map() + bits.set(tag, 0) + bits.set(keyName, 0) + const assigned = new Map() + const perVariant = new Map | undefined>() + let count = 0 + for (const variantName of Object.keys(variants)) { + const variant = variants[variantName] as Field & { readonly type?: string; readonly fields?: Fields } + if (variant.type !== "struct") { + perVariant.set(variantName, undefined) + continue + } + const fields: DiffField[] = [] + for (const name of Reflect.ownKeys(variant.fields!)) { + const field = variant.fields![name] + if (field.immutable) { + if (!assigned.has(name) && !bits.has(name)) bits.set(name, 0) + continue + } + const bit = assigned.get(name) ?? 1 << Math.min(count++, 30) + assigned.set(name, bit) + bits.set(name, bit) + fields.push({ name, field, bit }) + } + perVariant.set(variantName, fields) + } + return { bits, perVariant } + } + + function compileUnionDiff( + tag: PropertyKey, + variants: Readonly>>, + model: UnionDiffModel, + ) { + return (left: unknown, right: unknown) => { + const a = left as Record + const b = right as Record + const name = a[tag] + if (name !== b[tag] || typeof name !== "string") return -1 + const fields = model.perVariant.get(name) + if (!fields) { + const variant = variants[name] + return variant && variant.equivalent(left, right) ? 0 : -1 + } + let changed = 0 + for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit + return changed + } + } + + function generateUnionDiff( + tag: PropertyKey, + variants: Readonly>>, + model: UnionDiffModel, + ) { + const symbols = [...model.perVariant.values()].some((fields) => + fields?.some((field) => typeof field.name === "symbol"), + ) + if (typeof tag === "symbol" || symbols) return compileUnionDiff(tag, variants, model) + const emitter: Emitter = { custom: [], declarations: [], names: new Map() } + const property = JSON.stringify(String(tag)) + const cases = Object.keys(variants).map((name) => { + const label = JSON.stringify(name) + const fields = model.perVariant.get(name) + if (!fields) { + const index = emitter.custom.push(variants[name].equivalent) - 1 + return `case ${label}: return custom[${index}](left, right) ? 0 : -1` + } + const statements = fields.map((field) => { + const fieldProperty = JSON.stringify(String(field.name)) + return `if (!(${expression(emitter, field.field, `left[${fieldProperty}]`, `right[${fieldProperty}]`)})) changed |= ${field.bit}` + }) + return `case ${label}: { let changed = 0\n${statements.join("\n")}\nreturn changed }` + }) + const factory = Function( + "custom", + `${emitter.declarations.join("\n")}\nreturn (left, right) => { if (left[${property}] !== right[${property}]) return -1; switch (left[${property}]) { ${cases.join("\n")}\ndefault: return -1 } }`, + ) as (custom: ReadonlyArray["equivalent"]>) => (left: unknown, right: unknown) => number + return factory(emitter.custom) + } + + // Whole-tree generation: one Function() per plan whose source inlines struct + // comparisons, emits real loops for arrays, and hoists unions into named + // inner functions the engine can inline. User-supplied equivalence functions + // remain indirect calls through the `custom` array; everything structural + // compiles to direct code with no interior closure boundaries. + type Emitter = { + readonly custom: Array["equivalent"]> + readonly declarations: string[] + readonly names: Map + } + function generateEquivalent( - fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>, + fields: ReadonlyArray<{ + readonly name: PropertyKey + readonly field: Field + }>, ) { + // Symbol names cannot appear in generated source; degrade to the closure backend. if (fields.some((field) => typeof field.name === "symbol")) return compileEquivalent(fields) - const custom: Array["equivalent"]> = [] - const comparisons = fields.map((field) => { - const name = JSON.stringify(String(field.name)) - if (field.field.primitive) return `Object.is(left[${name}], right[${name}])` - const index = custom.push(field.field.equivalent) - 1 - return `custom[${index}](left[${name}], right[${name}])` - }) - const factory = Function("custom", `return (left, right) => ${comparisons.join(" && ") || "true"}`) as ( - custom: ReadonlyArray["equivalent"]>, - ) => (left: A, right: A) => boolean - return factory(custom) + const emitter: Emitter = { custom: [], declarations: [], names: new Map() } + const comparisons = fields + .map((field) => { + const property = JSON.stringify(String(field.name)) + return expression(emitter, field.field, `left[${property}]`, `right[${property}]`) + }) + .filter((comparison) => comparison !== "true") + return assemble(emitter, comparisons.join(" && ") || "true") } function generateUnion>>>( @@ -560,101 +827,106 @@ export namespace Layout { variants: Variants, ) { if (typeof tag === "symbol") return compileUnion(tag, variants) - const names = Object.keys(variants) - const custom = names.map((name) => generateField(variants[name])) - const cases = names - .map((name, index) => `case ${JSON.stringify(name)}: return custom[${index}](left, right)`) - .join(";") - const property = JSON.stringify(String(tag)) - const factory = Function( - "custom", - `return (left, right) => { if (left[${property}] !== right[${property}]) return false; switch (left[${property}]) { ${cases}; default: return false } }`, - ) as (custom: ReadonlyArray["equivalent"]>) => (left: unknown, right: unknown) => boolean - return factory(custom) + const emitter: Emitter = { custom: [], declarations: [], names: new Map() } + const root = declareUnion(emitter, { tag, variants }, tag, variants) + return assemble>(emitter, `${root}(left, right)`) } - function generateField(field: Field): Field["equivalent"] { - if (field.immutable) return () => true + function assemble(emitter: Emitter, body: string) { + const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as ( + custom: ReadonlyArray["equivalent"]>, + ) => (left: A, right: A) => boolean + return factory(emitter.custom) + } + + function expression(emitter: Emitter, field: Field, left: string, right: string): string { + if (field.immutable) return "true" + if (field.primitive) return `Object.is(${left}, ${right})` const layout = field as Field & { - readonly type?: "struct" | "union" | "keyed-union" + readonly type?: "struct" | "union" | "keyed-union" | "array" readonly fields?: Fields + readonly item?: Field readonly tag?: PropertyKey readonly variants?: Readonly>> readonly key?: NamedKey } - if (layout.type === "struct") { - const fields = Reflect.ownKeys(layout.fields!) + if (layout.type === "struct" && Reflect.ownKeys(layout.fields!).every((name) => typeof name !== "symbol")) { + const comparisons = Reflect.ownKeys(layout.fields!) .filter((name) => !layout.fields![name].immutable) - .map((name) => ({ name, field: layout.fields![name] })) - return generateEquivalent(generated(fields)) + .map((name) => { + const property = JSON.stringify(String(name)) + return expression(emitter, layout.fields![name], `${left}[${property}]`, `${right}[${property}]`) + }) + .filter((comparison) => comparison !== "true") + if (comparisons.length === 0) return "true" + // Shared-reference fast path: immutable updates reuse untouched sub-objects. + return `(${left} === ${right} || (${comparisons.join(" && ")}))` } - if (layout.type === "union") return generateUnion(layout.tag!, layout.variants!) - if (layout.type !== "keyed-union") return field.equivalent - const equivalent = generateUnion(layout.tag!, layout.variants!) as (left: unknown, right: unknown) => boolean - return (left, right) => { - const a = left as Record - const b = right as Record - return layout.key!.field.equivalent(a[layout.key!.name], b[layout.key!.name]) && equivalent(left, right) + if (layout.type === "array") { + const name = declare(emitter, layout, (fn) => { + const item = expression(emitter, layout.item!, "l[i]", "r[i]") + return `function ${fn}(l, r) { if (l === r) return true; if (l.length !== r.length) return false; for (let i = 0; i < l.length; i++) if (!(${item})) return false; return true }` + }) + return `${name}(${left}, ${right})` + } + if (layout.type === "union" && typeof layout.tag !== "symbol") { + return `${declareUnion(emitter, layout, layout.tag!, layout.variants!)}(${left}, ${right})` + } + if (layout.type === "keyed-union" && typeof layout.tag !== "symbol" && typeof layout.key!.name !== "symbol") { + const name = declare(emitter, layout, (fn) => { + const property = JSON.stringify(String(layout.key!.name)) + const key = expression(emitter, layout.key!.field, `l[${property}]`, `r[${property}]`) + const union = declareUnion(emitter, layout.variants, layout.tag!, layout.variants!) + return `function ${fn}(l, r) { return ${key === "true" ? "" : `${key} && `}${union}(l, r) }` + }) + return `${name}(${left}, ${right})` } + const index = emitter.custom.push(field.equivalent) - 1 + return `custom[${index}](${left}, ${right})` } - function generated(fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>) { - return fields.map((field) => ({ ...field, field: { ...field.field, equivalent: generateField(field.field) } })) + function declareUnion( + emitter: Emitter, + identity: unknown, + tag: PropertyKey, + variants: Readonly>>, + ) { + return declare(emitter, identity, (fn) => { + const property = JSON.stringify(String(tag)) + const cases = Object.keys(variants) + .map((name) => `case ${JSON.stringify(name)}: return ${expression(emitter, variants[name], "l", "r")}`) + .join("; ") + return `function ${fn}(l, r) { if (l[${property}] !== r[${property}]) return false; switch (l[${property}]) { ${cases}; default: return false } }` + }) } - function compileEquivalent(fields: ReadonlyArray<{ readonly name: PropertyKey; readonly field: Field }>) { - const value = (input: A) => input as Record + function declare(emitter: Emitter, identity: unknown, build: (name: string) => string) { + const existing = emitter.names.get(identity) + if (existing) return existing + const name = `q${emitter.names.size}` + emitter.names.set(identity, name) + // Reserve declaration order before build runs: build may recurse into + // declare for nested layouts, and the reserved slot keeps this function + // textually before its dependents without infinite recursion. + const index = emitter.declarations.push("") - 1 + emitter.declarations[index] = build(name) + return name + } + + // Compatibility comparator for the closure backend: a simple monomorphic + // loop. The generated backend is the performance path. + function compileEquivalent( + fields: ReadonlyArray<{ + readonly name: PropertyKey + readonly field: Field + }>, + ) { if (fields.length === 0) return (_left: A, _right: A) => true - if (fields.length === 1) { - const first = fields[0] - return (left: A, right: A) => first.field.equivalent(value(left)[first.name], value(right)[first.name]) - } - if (fields.length === 2) { - const first = fields[0] - const second = fields[1] - return (left: A, right: A) => { - const a = value(left) - const b = value(right) - return ( - first.field.equivalent(a[first.name], b[first.name]) && - second.field.equivalent(a[second.name], b[second.name]) - ) - } - } - if (fields.length === 3) { - const first = fields[0] - const second = fields[1] - const third = fields[2] - return (left: A, right: A) => { - const a = value(left) - const b = value(right) - return ( - first.field.equivalent(a[first.name], b[first.name]) && - second.field.equivalent(a[second.name], b[second.name]) && - third.field.equivalent(a[third.name], b[third.name]) - ) - } - } - if (fields.length === 4) { - const first = fields[0] - const second = fields[1] - const third = fields[2] - const fourth = fields[3] - return (left: A, right: A) => { - const a = value(left) - const b = value(right) - return ( - first.field.equivalent(a[first.name], b[first.name]) && - second.field.equivalent(a[second.name], b[second.name]) && - third.field.equivalent(a[third.name], b[third.name]) && - fourth.field.equivalent(a[fourth.name], b[fourth.name]) - ) - } - } return (left: A, right: A) => { - const a = value(left) - const b = value(right) - return fields.every((field) => field.field.equivalent(a[field.name], b[field.name])) + const a = left as Record + const b = right as Record + for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) return false + return true } } } diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts index 4cb290d2bdf2..d127bf52e99e 100644 --- a/packages/quark/src/reactivity.ts +++ b/packages/quark/src/reactivity.ts @@ -32,7 +32,14 @@ export namespace State { const state = signal(initial) const read = (() => state()) as Writable read.set = (value) => state(value) - read.update = (f) => state(f(state())) + read.update = (f) => { + // Read untracked: calling update inside an effect must not make the + // effect depend on (and re-trigger from) this signal. + const active = setActiveSub() + const current = state() + setActiveSub(active) + state(f(current)) + } read.subscribe = (listener) => subscribe(read, listener) return read } diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts index e7e480bd4b92..91bed6d37f80 100644 --- a/packages/quark/test/keyed.test.ts +++ b/packages/quark/test/keyed.test.ts @@ -127,7 +127,7 @@ describe("Keyed", () => { 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") + expect(keyed.update(item(3, "three"))).toBe(false) dispose() }) @@ -142,7 +142,7 @@ describe("Keyed", () => { expect(keyed.slots()[0]).toBe(slot) expect(slot()).toEqual(item(1, "ONE")) expect(() => keyed.modify(1, (value) => ({ ...value, id: 2 }))).toThrow("Keyed modify must preserve the value key") - expect(() => keyed.modify(2, (value) => value)).toThrow("Keyed value does not exist: 2") + expect(keyed.modify(2, (value) => value)).toBe(false) }) it("checks key membership without reading the aggregate", () => { From afd4f633c81dfb6f8f5f97a969bdcd44bd548c63 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 22:37:04 -0400 Subject: [PATCH 04/18] feat(tui): move assistant content into per-part quark slots --- docs/design/quark-message-content.md | 94 +++++++ packages/quark/src/solid.ts | 21 +- packages/tui/src/context/data.tsx | 226 ++++++++------- packages/tui/src/routes/session/content.ts | 91 ++++++ .../tui/src/routes/session/dialog-message.tsx | 4 +- packages/tui/src/routes/session/index.tsx | 266 +++++++----------- .../src/routes/session/message-navigation.ts | 5 +- .../tui/src/routes/session/permission.tsx | 26 +- packages/tui/src/routes/session/rows.ts | 6 +- packages/tui/src/routes/session/timeline.ts | 23 +- packages/tui/test/cli/tui/data.test.tsx | 113 ++++++-- 11 files changed, 547 insertions(+), 328 deletions(-) create mode 100644 docs/design/quark-message-content.md create mode 100644 packages/tui/src/routes/session/content.ts diff --git a/docs/design/quark-message-content.md b/docs/design/quark-message-content.md new file mode 100644 index 000000000000..ea6344f6c3ec --- /dev/null +++ b/docs/design/quark-message-content.md @@ -0,0 +1,94 @@ +# Quark-Owned Message Content Slots + +Status: implemented; drive parity pending + +## Summary + +Assistant message content currently lives as unkeyed arrays inside the +DataProvider Solid store. Streaming deltas mutate those arrays through +`produce`, and every consumer that resolves a part re-scans `content` +(`findLast`, `filter(type)[ordinal]`), so per-delta work scales with message +size instead of delta size. + +This experiment changes only content ownership: each assistant message's parts +become one Quark keyed collection with stable per-part slots. Streaming events +already name the slot on the wire — `ordinal` for text and reasoning, `callID` +for tools — so deltas address slots directly instead of being rediscovered. + +Message metadata (finish, usage, retry, error), the event protocol, timeline +rows, and view components' rendering logic remain unchanged. + +The experiment succeeds only if all checks pass: + +1. Existing TUI data and row tests preserve their behavior. +2. Per-delta work is O(1) in message size: one slot publication per delta, + zero structural publications, one markdown recompute scoped to the part. +3. The drive script passes unchanged. + +## Ownership Boundary + +``` +server events + | + v +DataProvider + ├── Solid store: session/message metadata (unchanged) + └── SessionContent: Keyed per assistant message <- this experiment + | | + | slots (structure) | slot (value channel) + v v + part row resolution TextPart / ReasoningPart / ToolPart +``` + +- Part identity: `text:{ordinal}` and `reasoning:{ordinal}` from event + ordinals, tool parts by `callID`. These match the synthetic part IDs the + timeline already uses, so `resolvePart`'s positional bridge disappears. +- Deltas are value-channel publications: `content.modify(key, ...)`. The + parts ``/group structure never re-runs on a delta. +- Part starts are structural insertions; `ended` events publish the + authoritative final value into the same slot. +- Full sync (`message.sync`) seeds each message's collection with `set(parts)` + derived from the fetched payload, assigning ordinals per type in order. +- Revert, session removal, and model-switch refetch drop or reseed collections. +- Cold consumers (transcript export, copy-last-response, message navigation, + permission lookup) read `values()` — plain arrays, same shapes as before. + +## Part Layout + +Tool identity fields are immutable; streamed fields are mutable. The field +diff mask means a tool status change skips text-driven work and vice versa. + +```ts +const PartLayout = Layout.keyedUnion({ + key: Layout.key("partID", Layout.string), + tag: "type", + variants: { + text: Layout.struct({ text: Layout.string }), + reasoning: Layout.struct({ text: Layout.string, state: ..., time: ... }), + tool: Layout.struct({ + id: Layout.immutable(Layout.string), + name: Layout.immutable(Layout.string), + state: ..., time: ..., executed: ..., providerState: ..., + }), + }, +}) +``` + +Store values are the client `SessionMessageAssistant*` content shapes plus the +synthetic `partID`; `values()` strips nothing because consumers already ignore +unknown fields. + +## What Is Not In Scope + +- Message metadata stays in the Solid store. +- No changes to server events or the client package types. +- No lazy or pull-based consumers; slots publish eagerly as today. +- Timeline row ownership is the previous experiment and is unchanged. + +## Measurement + +Instrument with `Keyed.Metrics`: a streaming test drives N deltas into a +message with M existing parts and asserts slot publications == N and +structural publications == parts started, independent of M. Markdown +recompute counts are asserted through the part component render counters in +the existing test harness. diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts index 2ca1ecf7bd11..6be648be09df 100644 --- a/packages/quark/src/solid.ts +++ b/packages/quark/src/solid.ts @@ -1,10 +1,29 @@ -import { For, from, type Accessor, type JSX } from "solid-js" +import { createMemo, For, from, type Accessor, type JSX } from "solid-js" +import type { Keyed } from "./keyed" import type { Readable } from "./reactivity" export function useValue(readable: Readable): Accessor { return from(readable, readable()) } +/** + * Reactive accessor for one keyed slot. Tracks structure only until the slot + * exists; afterwards value changes flow through the slot alone, so unrelated + * structural churn cannot re-render the consumer. + */ +export function useSlot(keyed: Keyed.Keyed, key: () => Key): Accessor { + const structure = useValue(keyed.slots) + const slot = createMemo(() => { + structure() + return keyed.get(key()) + }) + const value = createMemo(() => { + const current = slot() + return current ? useValue(current) : undefined + }) + return () => value()?.() +} + export function KeyedFor(props: { readonly each: Accessor[]> readonly fallback?: JSX.Element diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 14990fb93373..3dc776beeeb4 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -19,9 +19,6 @@ import type { ReferenceInfo, SessionMessageInfo, SessionMessageAssistant, - SessionMessageAssistantReasoning, - SessionMessageAssistantText, - SessionMessageAssistantTool, SessionInfo, SessionPendingInfo, ShellInfo, @@ -33,6 +30,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" import { createEffect, createSignal, onCleanup } from "solid-js" +import { SessionContent } from "../routes/session/content" export type DataSessionStatus = "idle" | "running" @@ -146,6 +144,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + // Assistant content lives in per-message keyed part slots, not the Solid + // store: streaming deltas publish one slot instead of reconciling arrays. + const content = SessionContent.make() const sync = createSync() function setSessionActive(sessionID: string, status: DataSessionStatus) { @@ -199,20 +200,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, - latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantTool => - item.type === "tool" && (callID === undefined || item.id === callID), - ) - }, - latestText(assistant: SessionMessageAssistant | undefined) { - return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") - }, - latestReasoning(assistant: SessionMessageAssistant | undefined) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, - ) - }, } function index(sessionID: string) { @@ -269,6 +256,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function removeSession(sessionID: string) { messageIndex.delete(sessionID) + content.drop(sessionID) sync.invalidate(`session:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.message:${sessionID}`) @@ -353,6 +341,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ void client.api.session .message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) .then((item) => { + if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content) message.update(event.data.sessionID, (draft, index) => { const position = index.get(item.id) if (position === undefined) return message.append(draft, index, item) @@ -540,143 +529,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } }) break - case "session.text.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ - type: "text", - text: "", - }) - }) + case "session.text.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + const partID = SessionContent.textID(event.data.ordinal) + if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID }) break + } case "session.text.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.textID(event.data.ordinal), (part) => + part.type === "text" ? { ...part, text: part.text + event.data.delta } : part, + ) break case "session.text.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text = event.data.text - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.textID(event.data.ordinal), (part) => + part.type === "text" ? { ...part, text: event.data.text } : part, + ) break - case "session.tool.input.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ + case "session.tool.input.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + if (!parts.has(event.data.callID)) + parts.insert({ type: "tool", id: event.data.callID, name: event.data.name, time: { created: event.created }, state: { status: "streaming", input: "" }, + partID: event.data.callID, }) - }) break + } case "session.tool.input.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status === "streaming") match.state.input += event.data.delta + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "streaming") return part + return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } } }) break case "session.tool.input.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status === "streaming") match.state.input = event.data.text + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "streaming") return part + return { ...part, state: { ...part.state, input: event.data.text } } }) break case "session.tool.called": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (!match) return - match.time.ran = event.created - match.executed = event.data.executed - match.providerState = event.data.state - match.state = { status: "running", input: event.data.input, structured: {}, content: [] } + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool") return part + return { + ...part, + time: { ...part.time, ran: event.created }, + executed: event.data.executed, + providerState: event.data.state, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + } }) break case "session.tool.progress": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status !== "running") return - match.state.structured = event.data.structured - match.state.content = [...event.data.content] + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "running") return part + return { + ...part, + state: { ...part.state, structured: event.data.structured, content: [...event.data.content] }, + } }) break case "session.tool.success": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status !== "running") return - match.state = { - status: "completed", - input: match.state.input, - structured: event.data.structured, - content: [...event.data.content], - result: event.data.result, + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "running") return part + return { + ...part, + state: { + status: "completed", + input: part.state.input, + structured: event.data.structured, + content: [...event.data.content], + result: event.data.result, + }, + executed: event.data.executed || part.executed === true, + providerResultState: event.data.resultState, + time: { ...part.time, completed: event.created }, } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.time.completed = event.created }) break case "session.tool.failed": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return - match.state = { - status: "error", - error: event.data.error, - input: typeof match.state.input === "string" ? {} : match.state.input, - structured: match.state.status === "running" ? match.state.structured : {}, - content: match.state.status === "running" ? match.state.content : [], - result: event.data.result, + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running")) + return part + return { + ...part, + state: { + status: "error", + error: event.data.error, + input: typeof part.state.input === "string" ? {} : part.state.input, + structured: part.state.status === "running" ? part.state.structured : {}, + content: part.state.status === "running" ? part.state.content : [], + result: event.data.result, + }, + executed: event.data.executed || part.executed === true, + providerResultState: event.data.resultState, + time: { ...part.time, completed: event.created }, } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.time.completed = event.created }) break - case "session.reasoning.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ + case "session.reasoning.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + const partID = SessionContent.reasoningID(event.data.ordinal) + if (!parts.has(partID)) + parts.insert({ type: "reasoning", text: "", state: event.data.state, time: { created: event.created }, + partID, }) - }) break + } case "session.reasoning.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => + part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part, + ) break case "session.reasoning.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) { - match.text = event.data.text - match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.state !== undefined) match.state = event.data.state - } - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => { + if (part.type !== "reasoning") return part + return { + ...part, + text: event.data.text, + time: { created: part.time?.created ?? event.created, completed: event.created }, + state: event.data.state !== undefined ? event.data.state : part.state, + } + }) break case "session.retry.scheduled": message.update(event.data.sessionID, (draft, index) => { @@ -745,7 +733,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft, index) => { const position = draft.findIndex((item) => item.id >= event.data.to) if (position === -1) return - for (const item of draft.splice(position)) index.delete(item.id) + for (const item of draft.splice(position)) { + index.delete(item.id) + content.drop(event.data.sessionID, item.id) + } }) break case "session.compaction.delta": @@ -964,9 +955,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ await client.api.message.list({ sessionID, limit: 200, order: "desc" }) ).data.toReversed() messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) + content.drop(sessionID) + for (const item of messages) { + if (item.type === "assistant") content.seed(sessionID, item.id, item.content) + } setStore("session", "message", sessionID, reconcile(messages)) }) }, + parts(sessionID: string, messageID: string) { + return content.ensure(sessionID, messageID) + }, invalidate(sessionID: string) { sync.invalidate(`session.message:${sessionID}`) }, diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts new file mode 100644 index 000000000000..4e4e737da951 --- /dev/null +++ b/packages/tui/src/routes/session/content.ts @@ -0,0 +1,91 @@ +import type { SessionMessageAssistant } from "@opencode-ai/client" +import { Keyed, Layout } from "effect-quark" + +/** + * Stable per-part reactive slots for assistant message content. + * + * Streaming events name their part on the wire (text/reasoning by ordinal, + * tools by callID); each assistant message owns one keyed collection so a + * delta publishes exactly one slot instead of reconciling a content array. + * See docs/design/quark-message-content.md. + */ +export namespace SessionContent { + type ContentPart = SessionMessageAssistant["content"][number] + export type Part = ContentPart & { readonly partID: string } + export type Parts = Keyed.Keyed + + // Streamed sub-objects are replaced immutably on change, so reference + // equality is the correct (and cheapest) field comparator for them. + const reference: Layout.Field = { equivalent: Object.is } + + const PartLayout = Layout.keyedUnion({ + key: Layout.key("partID", Layout.string), + tag: "type", + variants: { + text: Layout.struct({ text: Layout.string }), + reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }), + tool: Layout.struct({ + id: Layout.immutable(Layout.string), + name: Layout.immutable(Layout.string), + executed: reference, + providerState: reference, + providerResultState: reference, + state: reference, + time: reference, + }), + }, + }) + // The layout describes the reactive fields of the client content shapes; + // the plan is typed against those shapes at this single boundary. + const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan + + export function textID(ordinal: number) { + return `text:${ordinal}` + } + + export function reasoningID(ordinal: number) { + return `reasoning:${ordinal}` + } + + /** Assign the synthetic part IDs used across the TUI to a fetched content array. */ + export function withPartIDs(content: readonly ContentPart[]): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return content.map((part) => { + if (part.type === "tool") return { ...part, partID: part.id } + return { ...part, partID: `${part.type}:${ordinals[part.type]++}` } + }) + } + + export function make(options?: { readonly metrics?: Keyed.Metrics }) { + const collections = new Map() + const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}` + + return { + get(sessionID: string, messageID: string) { + return collections.get(id(sessionID, messageID)) + }, + ensure(sessionID: string, messageID: string) { + const key = id(sessionID, messageID) + const existing = collections.get(key) + if (existing) return existing + const created = PartPlan.make([], options) + collections.set(key, created) + return created + }, + seed(sessionID: string, messageID: string, content: readonly ContentPart[]) { + this.ensure(sessionID, messageID).set(withPartIDs(content)) + }, + /** Drop one message's collection, or every collection for a session. */ + drop(sessionID: string, messageID?: string) { + if (messageID !== undefined) { + collections.delete(id(sessionID, messageID)) + return + } + const prefix = `${sessionID}\u0000` + for (const key of [...collections.keys()]) { + if (key.startsWith(prefix)) collections.delete(key) + } + }, + } + } +} diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 89355246345a..e2216191770e 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -62,7 +62,9 @@ export function DialogMessage(props: { value.type === "user" ? value.text : value.type === "assistant" - ? value.content + ? data.session.message + .parts(props.sessionID, props.messageID) + .values() .filter((content) => content.type === "text") .map((content) => content.text) .join("\n") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 212ab7ee04a4..1f6b87e2f184 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -13,7 +13,8 @@ import { Switch, useContext, } from "solid-js" -import { KeyedFor } from "effect-quark/solid" +import { KeyedFor, useSlot, useValue } from "effect-quark/solid" +import { SessionContent } from "./content" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -74,7 +75,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" import { createSessionRows } from "./rows" -import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline" +import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline" import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" @@ -293,6 +294,11 @@ export function Session() { direction, children: scroll.getChildren(), messages: messages(), + hasText: (messageID) => + data.session.message + .parts(route.sessionID, messageID) + .values() + .some((part) => part.type === "text" && part.text.trim()), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, currentID: navigationMessage(), @@ -662,7 +668,10 @@ export function Session() { return } - const textParts = lastAssistantMessage.content.filter((part) => part.type === "text") + const textParts = data.session.message + .parts(route.sessionID, lastAssistantMessage.id) + .values() + .filter((part) => part.type === "text") if (textParts.length === 0) { toast.show({ message: "No text parts found in last assistant message", variant: "error" }) dialog.clear() @@ -700,7 +709,9 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) => + data.session.message.parts(route.sessionID, messageID).values(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -727,7 +738,9 @@ export function Session() { const content = options.format === "markdown" - ? formatSessionTranscript(sessionData, messages(), options.thinking) + ? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) => + data.session.message.parts(route.sessionID, messageID).values(), + ) : await (async () => { if (options.debug) { const events: { readonly created: number }[] = [] @@ -915,11 +928,15 @@ export function Session() { data.session.message.get(route.sessionID, messageID)} + parts={(messageID) => data.session.message.parts(route.sessionID, messageID)} boundaryID={boundaries()[index()]} /> )} - + data.session.message.parts(route.sessionID, messageID)} + /> SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts boundaryID?: string }) { return ( @@ -1023,10 +1041,17 @@ function SessionRowView(props: { - {(row) => } + {(row) => } - {(row) => } + {(row) => ( + + )} {(row) => ( @@ -1035,6 +1060,7 @@ function SessionRowView(props: { pending={row().pending} completed={row().completed} message={props.message} + parts={props.parts} /> )} @@ -1054,21 +1080,29 @@ function SessionRowView(props: { ) } -function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { +function BackgroundToolHint(props: { + messages: SessionMessageInfo[] + parts: (messageID: string) => SessionContent.Parts +}) { const { themeV2 } = useTheme() const shortcut = Keymap.useShortcut("session.background") - const visible = createMemo(() => { - const current = props.messages.findLast( + const current = createMemo(() => + props.messages.findLast( (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, - ) - return ( - current?.content.some((part) => { + ), + ) + const parts = createMemo(() => { + const message = current() + return message ? useValue(props.parts(message.id).values) : undefined + }) + const visible = createMemo( + () => + parts()?.()?.some((part) => { if (part.type !== "tool" || part.state.status !== "running") return false const display = toolDisplay(part.name) return display === "shell" || display === "subagent" - }) ?? false - ) - }) + }) ?? false, + ) return ( {(value) => ( @@ -1108,13 +1142,15 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { +function SessionPartView(props: { + partRef: PartRef + message: (messageID: string) => SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts +}) { const message = createMemo(() => props.message(props.partRef.messageID)) - const part = createMemo(() => { - const item = message() - if (item?.type !== "assistant") return - return resolvePart(item, props.partRef.partID) - }) + // One reactive slot per part: unrelated deltas in the same message cannot + // re-render this row. + const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID) return ( {(item) => ( @@ -1142,17 +1178,26 @@ function SessionReasoningGroupView(props: { refs: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts }) { const ctx = use() const { themeV2, syntax } = useTheme() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) + // Slot accessors are created per ref list; value changes flow through the + // individual slots without re-running the accessor construction. + const accessors = createMemo(() => + props.refs.map((ref) => ({ + ref, + part: useSlot(props.parts(ref.messageID), () => ref.partID), + })), + ) const parts = createMemo(() => - props.refs.flatMap((ref) => { - const message = props.message(ref.messageID) + accessors().flatMap((entry) => { + const message = props.message(entry.ref.messageID) if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) + const part = entry.part() if (part?.type !== "reasoning" || !reasoningContent(part)) return [] return [{ message, part }] }), @@ -1177,7 +1222,11 @@ function SessionReasoningGroupView(props: { 0}> {(ref) => }} + fallback={ + + {(ref) => } + + } > {(ref) => { - const message = createMemo(() => { - const item = props.message(ref.messageID) - return item?.type === "assistant" ? item : undefined - }) + const slot = useSlot(props.parts(ref.messageID), () => ref.partID) const part = createMemo(() => { - const item = message() - if (!item) return undefined - const part = resolvePart(item, ref.partID) - return part?.type === "reasoning" ? part : undefined + const item = slot() + return item?.type === "reasoning" ? item : undefined + }) + const messageCompleted = createMemo(() => { + const item = props.message(ref.messageID) + return item?.type === "assistant" && item.time.completed !== undefined }) const content = createMemo(() => { const item = part() @@ -1239,7 +1287,7 @@ function SessionReasoningGroupView(props: { SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts }) { const { themeV2 } = useTheme() const ctx = use() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - const parts = (refs: readonly PartRef[]) => - refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) + // Slot accessors per ref list: tool state changes flow through individual + // slots; the accessor lists rebuild only when the refs themselves change. + const slots = (refs: () => readonly PartRef[]) => + createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID))) + const groupedSlots = slots(() => props.refs) + const pendingSlots = slots(() => props.pending) + const parts = (accessors: readonly (() => SessionContent.Part | undefined)[]) => + accessors.flatMap((accessor) => { + const part = accessor() if (part?.type !== "tool") return [] return [part] }) - const grouped = createMemo(() => parts(props.refs)) - const pending = createMemo(() => parts(props.pending)) + const grouped = createMemo(() => parts(groupedSlots())) + const pending = createMemo(() => parts(pendingSlots())) const label = createMemo(() => { const counts = grouped().reduce>((result, part) => { const tool = toolDisplay(part.name) @@ -1690,124 +1743,6 @@ function UserMessage(props: { message: SessionMessageUser }) { ) } -function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { - const ctx = use() - const local = useLocal() - const { themeV2 } = useTheme().contextual("elevated") - const model = createMemo( - () => - ctx - .models() - .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id) - ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, - ) - - const final = createMemo(() => { - return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) - }) - - const duration = createMemo(() => { - if (!final()) return 0 - if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created - }) - - const exploration = createMemo(() => { - const grouped = new Map() - if (!ctx.groupExploration()) return grouped - const runs = props.message.content - .map((part) => - part.type === "tool" && - ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "streaming" - ? part - : undefined, - ) - .reduce( - (runs, part) => { - if (part) runs[runs.length - 1].push(part) - if (!part && runs[runs.length - 1].length) runs.push([]) - return runs - }, - [[]], - ) - .filter((run) => run.length > 0) - for (const run of runs) { - const summary = { - parts: run, - active: false, - } - run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) - } - return grouped - }) - - return ( - <> - - {(content, index) => ( - - - - - - - - - - } - > - {(summary) => } - - - - - )} - - - - {errorMessage(props.message.error)} - - - - - - - - - {Locale.titlecase(props.message.agent)} - - · {model()} - - · {Locale.duration(duration())} - - - - - - - ) -} - function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { const { themeV2 } = useTheme() return ( @@ -3012,13 +2947,18 @@ function recordValue(value: unknown): Record | undefined { return value as Record } -function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) { +function formatSessionTranscript( + session: SessionInfo, + messages: SessionMessageInfo[], + thinking: boolean, + partsOf: (messageID: string) => readonly SessionContent.Part[], +) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] - const content = message.content.flatMap((item) => { + const content = partsOf(message.id).flatMap((item) => { if (item.type === "text") return [item.text] if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : [] const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2) diff --git a/packages/tui/src/routes/session/message-navigation.ts b/packages/tui/src/routes/session/message-navigation.ts index 5e75fd5df100..29c212544079 100644 --- a/packages/tui/src/routes/session/message-navigation.ts +++ b/packages/tui/src/routes/session/message-navigation.ts @@ -19,6 +19,7 @@ export function findMessageBoundary(input: { direction: "next" | "prev" children: readonly MessageChild[] messages: readonly SessionMessageInfo[] + hasText?: (messageID: string) => boolean scrollTop: number viewportY: number currentID?: string @@ -35,7 +36,9 @@ export function findMessageBoundary(input: { return [{ id: child.id, y, top: y }] } if (input.userOnly || message.type !== "assistant") return [] - if (!message.content.some((content) => content.type === "text" && content.text.trim())) return [] + const hasText = + input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim()) + if (!hasText) return [] const y = input.scrollTop + child.y - input.viewportY return [{ id: child.id, y, top: Math.max(0, y - 1) }] }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 3b3ecf41af8e..97c385227680 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -146,9 +146,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const input = createMemo(() => { const tool = props.request.source if (!tool) return {} - const message = data.session.message.get(props.request.sessionID, tool.messageID) - if (message?.type !== "assistant") return {} - const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) + const part = data.session.message.parts(props.request.sessionID, tool.messageID).get(tool.callID)?.() if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input return {} }) @@ -167,7 +165,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director - This will allow the following patterns until OpenCode is restarted + + This will allow the following patterns until OpenCode is restarted + {(pattern) => ( @@ -643,10 +643,7 @@ function Prompt>(props: { ] : []), ], - bindings: [ - ...(props.escapeKey ? ["app.exit"] : []), - ...(props.fullscreen ? ["permission.prompt.fullscreen"] : []), - ], + bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])], })) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) @@ -703,20 +700,14 @@ function Prompt>(props: { setStore("selected", option)} onMouseUp={() => { setStore("selected", option) props.onSelect(option) }} > - + {props.options[option]} @@ -726,7 +717,8 @@ function Prompt>(props: { - {shortcuts.get("permission.prompt.fullscreen")} {hint()} + {shortcuts.get("permission.prompt.fullscreen")}{" "} + {hint()} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 0af3239b0d60..ec3c42c153c9 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -42,7 +42,11 @@ export function createSessionRows(sessionID: Accessor, options?: { reado const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const rows = reduceSessionRows( + boundary ? messages.filter((message) => message.id < boundary) : messages, + inputs, + (message) => data.session.message.parts(sessionID(), message.id).values(), + ) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( position === -1 ? rows.length : position, diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts index be59c6353dd6..1b7020362271 100644 --- a/packages/tui/src/routes/session/timeline.ts +++ b/packages/tui/src/routes/session/timeline.ts @@ -1,5 +1,6 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { Keyed, Layout, Transaction } from "effect-quark" +import { SessionContent } from "./content" const PartRefLayout = Layout.struct({ messageID: Layout.string, @@ -180,7 +181,11 @@ export namespace SessionTimeline { } } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { +export function reduceSessionRows( + messages: SessionMessageInfo[], + inputs = new Set(), + partsOf?: (message: SessionMessageAssistant & { id: string }) => readonly SessionContent.Part[], +) { const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) @@ -195,11 +200,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S rows.push(messageRow(message.id)) return rows } - const ordinals = { text: 0, reasoning: 0 } - message.content.forEach((part) => { - const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` + const parts = partsOf ? partsOf(message) : SessionContent.withPartIDs(message.content) + parts.forEach((part) => { if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID }, part) + append(rows, { messageID: message.id, partID: part.partID }, part) }) if (isTerminalFinish(message.finish) || message.error || message.retry) { completePrevious(rows) @@ -239,15 +243,6 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map part.type === "tool" && part.id === partID) - if (tool) return tool - const match = /^(text|reasoning):(\d+)$/.exec(partID) - if (!match) return - const ordinal = Number(match[2]) - return message.content.filter((part) => part.type === match[1])[ordinal] -} - export function isTerminalFinish(finish: string | undefined) { return !!finish && !["tool-calls", "unknown"].includes(finish) } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fd39b0f744d0..a14a842846fd 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -883,6 +883,96 @@ test("classifies live tool rows independently of their call ID", async () => { } }) +test("publishes streaming deltas to one part slot without touching siblings", async () => { + const events = createEventStream() + const sessionID = "session-part-slots" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_slots_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_slots_tool", + created: 2, + type: "session.tool.input.started", + durable: durable(sessionID, 1), + data: { sessionID, assistantMessageID: "message-assistant", callID: "call-slots", name: "read" }, + }) + emitEvent(events, { + id: "evt_slots_text", + created: 3, + type: "session.text.started", + durable: durable(sessionID, 2), + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 }, + }) + const parts = data.session.message.parts(sessionID, "message-assistant") + await wait(() => parts.get("text:0") !== undefined && parts.get("call-slots") !== undefined) + + const publications = { text: 0, tool: 0, structure: 0 } + const disposers = [ + parts.get("text:0")!.subscribe(() => publications.text++), + parts.get("call-slots")!.subscribe(() => publications.tool++), + parts.slots.subscribe(() => publications.structure++), + ] + emitEvent(events, { + id: "evt_slots_delta_1", + created: 4, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" }, + }) + emitEvent(events, { + id: "evt_slots_delta_2", + created: 5, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" }, + }) + await wait(() => { + const part = parts.get("text:0")?.() + return part?.type === "text" && part.text === "onetwo" + }) + + // One slot publication per delta; the sibling tool slot and the part + // structure never publish, independent of message size. + expect(publications).toEqual({ text: 2, tool: 0, structure: 0 }) + disposers.forEach((dispose) => dispose()) + } finally { + app.renderer.destroy() + } +}) + test("does not publish timeline rows for duplicate streaming deltas", async () => { const events = createEventStream() const sessionID = "session-stream-metrics" @@ -950,10 +1040,8 @@ test("does not publish timeline rows for duplicate streaming deltas", async () = 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") - ) + const part = data.session.message.parts(sessionID, "message-assistant").get("text:0")?.() + return part?.type === "text" && part.text === "onetwo" }) expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 }) @@ -2482,12 +2570,9 @@ test("settles pending tools when a live failure arrives", async () => { }) await wait(() => { - const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") + const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() return ( - assistant?.type === "assistant" && - assistant.content[0]?.type === "tool" && - assistant.content[0].state.status === "running" && - assistant.content[0].state.structured.sessionID === "session-child" + part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child" ) }) @@ -2507,19 +2592,15 @@ test("settles pending tools when a live failure arrives", async () => { }) await wait(() => { - const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") - return ( - assistant?.type === "assistant" && - assistant.content[0]?.type === "tool" && - assistant.content[0].state.status === "error" - ) + const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() + return part?.type === "tool" && part.state.status === "error" }) const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") expect(assistant?.type).toBe("assistant") if (assistant?.type !== "assistant") return expect(assistant.id).toBe("msg_explicit_assistant_9") - const tool = assistant.content[0] + const tool = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() expect(tool?.type).toBe("tool") if (tool?.type !== "tool") return expect(tool.state.status).toBe("error") From 6f7fc0c3a822727ce87fc92bb5217852e385ffd8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 22:42:30 -0400 Subject: [PATCH 05/18] chore: fix drive script type narrowing --- script/quark-timeline-drive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/quark-timeline-drive.ts b/script/quark-timeline-drive.ts index c9c671a2eb0e..3f91087ad230 100644 --- a/script/quark-timeline-drive.ts +++ b/script/quark-timeline-drive.ts @@ -36,9 +36,9 @@ export default defineScript({ (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) + if (!assistant.content.some((part) => part.type === "reasoning" && part.text === reasoning)) throw new Error("the projected reasoning content did not match") - if (assistant.content.find((part) => part.type === "text")?.text !== response) + if (!assistant.content.some((part) => part.type === "text" && part.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}`)) From ce5e1586acb8bfcbc880d484f36140058fc7b4d3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 22:53:35 -0400 Subject: [PATCH 06/18] chore(quark): sync vendored keyed and layout with standalone --- packages/quark/src/keyed.ts | 1 + packages/quark/src/layout.ts | 111 ++++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts index 7468ef72dabe..32040703a54c 100644 --- a/packages/quark/src/keyed.ts +++ b/packages/quark/src/keyed.ts @@ -133,6 +133,7 @@ export namespace Keyed { function publish(slot: Writable, value: A) { const current = slot() + // alien-signals uses SameValueZero-compatible identity for primitive writes. if (current === value || equivalent(current, value)) { if (options.metrics) options.metrics.equivalenceSuppressions++ return false diff --git a/packages/quark/src/layout.ts b/packages/quark/src/layout.ts index adb6fa1964c8..85677be2c810 100644 --- a/packages/quark/src/layout.ts +++ b/packages/quark/src/layout.ts @@ -41,8 +41,10 @@ export namespace Layout { readonly fields: StructFields } - export interface Union>>> - extends Field> { + export interface Union< + Tag extends PropertyKey, + Variants extends Readonly>>, + > extends Field> { readonly type: "union" readonly tag: Tag readonly variants: Variants @@ -66,7 +68,7 @@ export namespace Layout { readonly equivalent: (left: A, right: A) => boolean /** Bitmask of changed top-level fields; 0 means equivalent. Consistent with `equivalent`. */ readonly diff: (left: A, right: A) => number - /** Every known top-level property name to its change bit; immutable, key, and tag names map to 0. */ + /** Every known top-level property name to its change bit; immutable and key names map to 0. */ readonly bits: ReadonlyMap readonly keyOf: (value: A) => Key make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed @@ -215,8 +217,7 @@ export namespace Layout { >(layout: KeyedUnion, options?: CompileOptions): Plan, A> export function compile(input: unknown, options: CompileOptions = {}): unknown { const layout = input as - | Struct - | KeyedUnion>>> + Struct | KeyedUnion>>> if (layout.type === "keyed-union") { const model = unionDiffModel(layout.key.name, layout.tag, layout.variants) return makePlan( @@ -311,17 +312,14 @@ export namespace Layout { initial: readonly A[], options?: { readonly metrics?: Keyed.Metrics }, ): Collection { - // The most recent publication's field diff, captured from the equivalence - // callback so index refresh can gate projections on it. `pending` hands a - // diff the collection already computed for a staged mutation to the - // comparator, so equivalence costs one comparison, not two. - let lastDiff = -1 - let pending = -1 + // `pending` hands a diff the collection already computed for a staged + // mutation to the comparator, so equivalence costs one comparison, not two. + let pending: number | ReadonlyMap | undefined const values = Keyed.make({ key: plan.keyOf, equivalent(left, right) { - lastDiff = pending !== -1 ? pending : plan.diff(left, right) - return lastDiff === 0 + const mask = typeof pending === "number" ? pending : pending?.get(plan.keyOf(left)) + return (mask ?? plan.diff(left, right)) === 0 }, metrics: options?.metrics, }) @@ -348,15 +346,20 @@ export namespace Layout { }, getOwnPropertyDescriptor(_, property) { trackMask = -1 - return Reflect.getOwnPropertyDescriptor(trackTarget!, property) + const descriptor = Reflect.getOwnPropertyDescriptor(trackTarget!, property) + return descriptor && { ...descriptor, configurable: true } }, }) function tracked(value: A, f: (value: A) => T) { trackTarget = value as Record trackMask = 0 - const result = f(trackProxy as A) - trackTarget = undefined - return result + try { + const result = f(trackProxy as A) + return { result, reads: trackMask } + } finally { + trackTarget = undefined + trackMask = 0 + } } // Each index entry stages user-code projections before any state mutation @@ -394,18 +397,22 @@ export namespace Layout { name, reads: (key) => readsByKey.get(key), stage(key, value) { - const source = tracked(value, extract) - const reads = trackMask const previous = byKey.get(key) - const members = source === previous?.source ? previous.members : new Set(source) + const trackedMembers = tracked(value, (candidate) => { + const source = extract(candidate) + return { + source, + members: source === previous?.source ? previous.members : new Set(source), + } + }) return () => { - readsByKey.set(key, reads) - if (!previous) members.forEach((member) => adjust(member, 1)) - if (previous && previous.members !== members) { - members.forEach((member) => !previous.members.has(member) && adjust(member, 1)) - previous.members.forEach((member) => !members.has(member) && adjust(member, -1)) + readsByKey.set(key, trackedMembers.reads) + if (!previous) trackedMembers.result.members.forEach((member) => adjust(member, 1)) + if (previous && previous.members !== trackedMembers.result.members) { + trackedMembers.result.members.forEach((member) => !previous.members.has(member) && adjust(member, 1)) + previous.members.forEach((member) => !trackedMembers.result.members.has(member) && adjust(member, -1)) } - byKey.set(key, { source, members }) + byKey.set(key, trackedMembers.result) } }, applyDelta(key, change) { @@ -468,18 +475,17 @@ export namespace Layout { reads: (key) => readsByKey.get(key), stage(key, value, mode) { const matched = tracked(value, matches) - const reads = trackMask return (valueSlot) => { - readsByKey.set(key, reads) + readsByKey.set(key, matched.reads) const previous = matching.has(key) - if (matched) matching.add(key) - if (!matched) matching.delete(key) + if (matched.result) matching.add(key) + if (!matched.result) matching.delete(key) if (mode === "add") return - if (slot === valueSlot && !matched) { + if (slot === valueSlot && !matched.result) { findFirst() return } - if (slot !== valueSlot && !previous && matched) afterPlacement(valueSlot) + if (slot !== valueSlot && !previous && matched.result) afterPlacement(valueSlot) } }, remove(key, removedSlot) { @@ -524,19 +530,25 @@ export namespace Layout { // Indexes are plain data, not reactive state; the transaction exists so // subscribers cannot observe published values with stale indexes. With // no staged commits there is nothing to observe out of order. - if (!staged) { - pending = mask - const changed = values.update(value) - pending = -1 - return changed - } + if (!staged) return publishValue() return Transaction.run(() => { - pending = mask - const changed = values.update(value) - pending = -1 + const changed = publishValue() if (changed) staged.forEach((commit) => commit(slot)) return changed }) + + function publishValue() { + return withPending(mask, () => values.update(value)) + } + } + + function withPending(mask: number | ReadonlyMap, f: () => T) { + pending = mask + try { + return f() + } finally { + pending = undefined + } } const collection: Collection = { @@ -549,6 +561,7 @@ export namespace Layout { const value = slot() existing.set(plan.keyOf(value), value) }) + const masks = new Map() // Stage user-code projections for new and changed values only; // retained equivalent values keep their index state and reads. const staged: Array<{ readonly key: Key; readonly commits: readonly Commit[] }> = [] @@ -559,13 +572,14 @@ export namespace Layout { return } const mask = plan.diff(existing.get(key)!, value) + masks.set(key, mask) if (mask === 0) return const commits = stageRefresh(key, value, mask) if (commits) staged.push({ key, commits }) }) const retained = new Set(keys) return Transaction.run(() => { - const changed = values.set(next) + const changed = withPending(masks, () => values.set(next)) existing.forEach((_value, key) => { if (!retained.has(key)) entries.forEach((entry) => entry.remove(key)) }) @@ -713,7 +727,7 @@ export namespace Layout { variants: Readonly>>, ): UnionDiffModel { const bits = new Map() - bits.set(tag, 0) + bits.set(tag, 1) bits.set(keyName, 0) const assigned = new Map() const perVariant = new Map | undefined>() @@ -731,7 +745,7 @@ export namespace Layout { if (!assigned.has(name) && !bits.has(name)) bits.set(name, 0) continue } - const bit = assigned.get(name) ?? 1 << Math.min(count++, 30) + const bit = assigned.get(name) ?? 1 << Math.min(count++ + 1, 30) assigned.set(name, bit) bits.set(name, bit) fields.push({ name, field, bit }) @@ -777,7 +791,8 @@ export namespace Layout { const label = JSON.stringify(name) const fields = model.perVariant.get(name) if (!fields) { - const index = emitter.custom.push(variants[name].equivalent) - 1 + const variant = variants[name] + const index = emitter.custom.push((left, right) => variant.equivalent(left, right)) - 1 return `case ${label}: return custom[${index}](left, right) ? 0 : -1` } const statements = fields.map((field) => { @@ -841,7 +856,7 @@ export namespace Layout { function expression(emitter: Emitter, field: Field, left: string, right: string): string { if (field.immutable) return "true" - if (field.primitive) return `Object.is(${left}, ${right})` + if (field.primitive && field.equivalent === Object.is) return `Object.is(${left}, ${right})` const layout = field as Field & { readonly type?: "struct" | "union" | "keyed-union" | "array" readonly fields?: Fields @@ -876,12 +891,12 @@ export namespace Layout { const name = declare(emitter, layout, (fn) => { const property = JSON.stringify(String(layout.key!.name)) const key = expression(emitter, layout.key!.field, `l[${property}]`, `r[${property}]`) - const union = declareUnion(emitter, layout.variants, layout.tag!, layout.variants!) + const union = declareUnion(emitter, {}, layout.tag!, layout.variants!) return `function ${fn}(l, r) { return ${key === "true" ? "" : `${key} && `}${union}(l, r) }` }) return `${name}(${left}, ${right})` } - const index = emitter.custom.push(field.equivalent) - 1 + const index = emitter.custom.push((left, right) => field.equivalent(left, right)) - 1 return `custom[${index}](${left}, ${right})` } From 624ebebcea4f805de1abedc2d8e33e0552cf7426 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:08:05 -0400 Subject: [PATCH 07/18] fix(tui): preserve part collection identity across sync --- packages/tui/src/context/data.tsx | 2 +- packages/tui/src/routes/session/content.ts | 12 +++ packages/tui/test/cli/tui/data.test.tsx | 96 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 3dc776beeeb4..1fd768dfbb39 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -955,7 +955,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ await client.api.message.list({ sessionID, limit: 200, order: "desc" }) ).data.toReversed() messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - content.drop(sessionID) + content.prune(sessionID, new Set(messages.map((message) => message.id))) for (const item of messages) { if (item.type === "assistant") content.seed(sessionID, item.id, item.content) } diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts index 4e4e737da951..8692879503b7 100644 --- a/packages/tui/src/routes/session/content.ts +++ b/packages/tui/src/routes/session/content.ts @@ -86,6 +86,18 @@ export namespace SessionContent { if (key.startsWith(prefix)) collections.delete(key) } }, + /** + * Drop collections for messages absent from a refetched snapshot. + * Present messages must be reseeded in place, never dropped: mounted + * views hold their collection reference for their whole lifetime, so + * replacing the object would orphan their subscriptions. + */ + prune(sessionID: string, keep: ReadonlySet) { + const prefix = `${sessionID}\u0000` + for (const key of [...collections.keys()]) { + if (key.startsWith(prefix) && !keep.has(key.slice(prefix.length))) collections.delete(key) + } + }, } } } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index a14a842846fd..48470b0a038f 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -973,6 +973,102 @@ test("publishes streaming deltas to one part slot without touching siblings", as } }) +test("keeps part slot references alive across message sync", async () => { + const events = createEventStream() + const sessionID = "session-slot-identity" + // The refetch snapshot is identical to the streamed state, so any failure + // here is collection identity loss, not snapshot staleness. + let snapshot: unknown[] = [] + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: snapshot, cursor: {} }) + }, events) + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_identity_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_identity_text", + created: 2, + type: "session.text.started", + durable: durable(sessionID, 1), + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 }, + }) + emitEvent(events, { + id: "evt_identity_ended", + created: 3, + type: "session.text.ended", + durable: durable(sessionID, 2), + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, text: "hello world" }, + }) + // A mounted view captures the collection once, at useSlot setup. + const mounted = data.session.message.parts(sessionID, "message-assistant") + await wait(() => { + const part = mounted.get("text:0")?.() + return part?.type === "text" && part.text === "hello world" + }) + + snapshot = [ + { + id: "message-assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "hello world" }], + time: { created: 1 }, + }, + ] + await data.session.message.sync(sessionID) + + // The registry must hand back the same collection the view is holding. + expect(data.session.message.parts(sessionID, "message-assistant")).toBe(mounted) + + emitEvent(events, { + id: "evt_identity_delta", + created: 4, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "!!" }, + }) + // The delta must reach the reference a mounted view is subscribed to. + await wait(() => { + const part = mounted.get("text:0")?.() + return part?.type === "text" && part.text === "hello world!!" + }) + } finally { + app.renderer.destroy() + } +}) + test("does not publish timeline rows for duplicate streaming deltas", async () => { const events = createEventStream() const sessionID = "session-stream-metrics" From d0c6fc271e890ae7c9f91467cc6a2fd96b708f43 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:17:36 -0400 Subject: [PATCH 08/18] fix(tui): preserve prompts during session hydration Port of #36433 onto the quark timeline branch: pending operations journal, pending-derived inputs, and hydration that reappends admitted-but-unprojected prompts. Composed with part-collection reseeding. --- packages/tui/src/context/data.tsx | 159 +++++++++++++++--------- packages/tui/src/routes/session/rows.ts | 1 - packages/tui/test/cli/tui/data.test.tsx | 140 +++++++++++++++++++-- packages/tui/test/fixture/tui-client.ts | 1 + 4 files changed, 229 insertions(+), 72 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 1fd768dfbb39..504f99ac930a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,7 +1,7 @@ // Client data layer: apply server events and cache API reads into a Solid store. -// Prefer straightforward projection. Do not add generation counters, stale-response -// merges, live/history overlays, or other race machinery here—last write wins. -// Reconnect invalidates cached reads; active UI owners decide what to sync again. +// Prefer straightforward projection. API reads replace cached state, except admitted +// inputs survive history refresh until the server projects them. Reconnect invalidates +// cached reads; active UI owners decide what to sync again. import type { AgentInfo, @@ -69,7 +69,6 @@ type Store = { active: Record message: Record pending: Record - input: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record @@ -80,6 +79,11 @@ type Store = { location: Record } +type PendingOperation = + | { type: "admitted"; item: SessionPendingInfo } + | { type: "promoted"; inputID: string } + | { type: "reverted"; to: string } + function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } @@ -129,7 +133,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ active: {}, message: {}, pending: {}, - input: {}, permission: {}, form: {}, }, @@ -148,18 +151,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // store: streaming deltas publish one slot instead of reconciling arrays. const content = SessionContent.make() const sync = createSync() + const pendingOperations = new Map() function setSessionActive(sessionID: string, status: DataSessionStatus) { setStore("session", "active", sessionID, status) } function addPending(item: SessionPendingInfo) { + pendingOperations.get(item.sessionID)?.push({ type: "admitted", item }) if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item]) } function removePending(sessionID: string, inputID?: string) { if (!inputID) return + pendingOperations.get(sessionID)?.push({ type: "promoted", inputID }) setStore( "session", "pending", @@ -168,6 +174,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } + function pendingInputs(sessionID: string) { + return (store.session.pending[sessionID] ?? []).filter((item) => item.type !== "compaction") + } + + function syncPending(sessionID: string) { + return sync.run(`session.pending:${sessionID}`, async () => { + const operations = pendingOperations.get(sessionID) ?? [] + pendingOperations.set(sessionID, operations) + try { + const pending = new Map((await client.api.session.pending.list({ sessionID })).map((item) => [item.id, item])) + operations.forEach((operation) => { + if (operation.type === "admitted") { + pending.set(operation.item.id, operation.item) + return + } + if (operation.type === "promoted") { + pending.delete(operation.inputID) + return + } + pending.forEach((_, id) => { + if (id >= operation.to) pending.delete(id) + }) + }) + setStore("session", "pending", sessionID, reconcile([...pending.values()])) + } finally { + if (pendingOperations.get(sessionID) === operations) pendingOperations.delete(sessionID) + } + }) + } + const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -200,6 +236,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, + fromPending(item: SessionPendingInfo): SessionMessageInfo { + if (item.type === "user") + return { + id: item.id, + type: "user", + ...item.data, + time: { created: item.timeCreated }, + } + if (item.type === "synthetic") + return { + id: item.id, + type: "synthetic", + ...item.data, + time: { created: item.timeCreated }, + } + return { + id: item.id, + type: "compaction", + status: "running", + reason: "manual", + summary: "", + recent: "", + time: { created: item.timeCreated }, + } + }, } function index(sessionID: string) { @@ -257,6 +318,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function removeSession(sessionID: string) { messageIndex.delete(sessionID) content.drop(sessionID) + pendingOperations.delete(sessionID) sync.invalidate(`session:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.message:${sessionID}`) @@ -269,7 +331,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ delete draft.active[sessionID] delete draft.message[sessionID] delete draft.pending[sessionID] - delete draft.input[sessionID] delete draft.permission[sessionID] delete draft.form[sessionID] for (const [rootID, family] of Object.entries(draft.family)) { @@ -363,59 +424,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break case "session.input.promoted": { + const pending = store.session.pending[event.data.sessionID]?.some((item) => item.id === event.data.inputID) removePending(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) if (position === undefined) return const existing = draft[position] - if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return + if (!existing || !pending) return existing.time.created = event.created draft.splice(position, 1) draft.push(existing) index.clear() draft.forEach((message, indexValue) => index.set(message.id, indexValue)) }) - setStore( - "session", - "input", - event.data.sessionID, - (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), - ) break } - case "session.input.admitted": - addPending({ + case "session.input.admitted": { + const pending: SessionPendingInfo = { id: event.data.inputID, sessionID: event.data.sessionID, admittedSeq: event.durable.seq, timeCreated: event.created, ...event.data.input, - }) - if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) - setStore("session", "input", event.data.sessionID, [ - ...(store.session.input[event.data.sessionID] ?? []), - event.data.inputID, - ]) + } + addPending(pending) message.update(event.data.sessionID, (draft, index) => { - message.append( - draft, - index, - event.data.input.type === "user" - ? { - id: event.data.inputID, - type: "user", - ...event.data.input.data, - time: { created: event.created }, - } - : { - id: event.data.inputID, - type: "synthetic", - ...event.data.input.data, - time: { created: event.created }, - }, - ) + message.append(draft, index, message.fromPending(pending)) }) break + } case "session.instructions.updated": const instructions = event.metadata?.instructions if ( @@ -724,11 +761,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) } + pendingOperations.get(event.data.sessionID)?.push({ type: "reverted", to: event.data.to }) setStore( "session", - "input", + "pending", event.data.sessionID, - (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to), + (store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to), ) message.update(event.data.sessionID, (draft, index) => { const position = draft.findIndex((item) => item.id >= event.data.to) @@ -905,10 +943,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, input: { list(sessionID: string) { - return store.session.input[sessionID] ?? [] + return pendingInputs(sessionID).map((item) => item.id) }, has(sessionID: string, inputID: string) { - return store.session.input[sessionID]?.includes(inputID) ?? false + return pendingInputs(sessionID).some((item) => item.id === inputID) }, }, pending: { @@ -916,16 +954,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.pending[sessionID] ?? [] }, sync(sessionID: string) { - return sync.run(`session.pending:${sessionID}`, async () => { - const pending = await client.api.session.pending.list({ sessionID }) - setStore("session", "pending", sessionID, reconcile(pending)) - setStore( - "session", - "input", - sessionID, - reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), - ) - }) + return syncPending(sessionID) }, invalidate(sessionID: string) { sync.invalidate(`session.pending:${sessionID}`) @@ -951,15 +980,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, sync(sessionID: string) { return sync.run(`session.message:${sessionID}`, async () => { - const messages = ( - await client.api.message.list({ sessionID, limit: 200, order: "desc" }) - ).data.toReversed() - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - content.prune(sessionID, new Set(messages.map((message) => message.id))) - for (const item of messages) { + await syncPending(sessionID) + const localInputs = pendingInputs(sessionID).map((item) => item.id) + const pendingMessages = [...(store.session.pending[sessionID] ?? [])].map(message.fromPending) + const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" }) + const next = projected.data.toReversed() + const index = new Map(next.map((message, index) => [message.id, index])) + const localInputIDs = new Set([...localInputs, ...pendingInputs(sessionID).map((item) => item.id)]) + localInputIDs.forEach((messageID) => { + const position = messageIndex.get(sessionID)?.get(messageID) + const item = position === undefined ? undefined : store.session.message[sessionID]?.[position] + if (item) message.append(next, index, item) + }) + pendingMessages.forEach((item) => message.append(next, index, item)) + messageIndex.set(sessionID, index) + // Reseed part collections in place from the final hydrated list; + // prune only collections whose messages are truly gone. + content.prune(sessionID, new Set(next.map((message) => message.id))) + for (const item of next) { if (item.type === "assistant") content.seed(sessionID, item.id, item.content) } - setStore("session", "message", sessionID, reconcile(messages)) + setStore("session", "message", sessionID, reconcile(next)) }) }, parts(sessionID: string, messageID: string) { diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index ec3c42c153c9..90cf0dfb00a7 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -76,7 +76,6 @@ export function createSessionRows(sessionID: Accessor, options?: { reado on([sessionID, () => client.connection.status()], ([id, status]) => { if (status !== "connected") return setRows(reduce()) - void data.session.pending.sync(id).catch(() => undefined) void data.session.message.sync(id).then( () => { if (sessionID() !== id) return diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 48470b0a038f..4a937a986542 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2723,16 +2723,40 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts immediately and tracks them until promoted", async () => { +test("preserves admitted prompts when hydration races with promotion", async () => { const events = createEventStream() const sessionID = "session-1" const messageID = "msg_user_1" + const queuedID = "msg_user_2" + const requested = Promise.withResolvers() + const response = Promise.withResolvers() const calls = createFetch((url) => { - if (url.pathname === `/api/session/${sessionID}/message`) + if (url.pathname === `/api/session/${sessionID}/pending`) return json({ - data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }], - cursor: {}, + data: [ + { + admittedSeq: 0, + id: messageID, + sessionID, + timeCreated: 0, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }, + { + admittedSeq: 1, + id: queuedID, + sessionID, + timeCreated: 1, + type: "user", + data: { text: "queued" }, + delivery: "queue", + }, + ], }) + if (url.pathname !== `/api/session/${sessionID}/message`) return + requested.resolve() + return response.promise }, events) let sync!: ReturnType let ready!: () => void @@ -2790,12 +2814,11 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn ]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) - await sync.session.message.sync(sessionID) - expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() - + const refresh = sync.session.message.sync(sessionID) + await requested.promise emitEvent(events, { id: "evt_prompted_1", - created: 0, + created: 1, type: "session.input.promoted", durable: durable(sessionID, 1), data: { @@ -2807,14 +2830,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn await wait(() => received.at(-1) === "session.input.promoted") expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"]) unsubscribe() - const message = sync.session.message.list(sessionID)?.[0] + response.resolve(json({ data: [], cursor: {} })) + await refresh + + const message = sync.session.message.get(sessionID, messageID) expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) expect(message.metadata).toBeUndefined() - expect(sync.session.pending.list(sessionID)).toEqual([]) - expect(sync.session.input.list(sessionID)).toEqual([]) - expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID]) + expect(sync.session.input.list(sessionID)).toEqual([queuedID]) + expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID, queuedID]) + expect(sync.session.message.get(sessionID, queuedID)).toMatchObject({ id: queuedID, text: "queued" }) expect(sync.session.message.list("missing")).toEqual([]) expect(sync.session.message.get(sessionID, messageID)).toBe(message) expect(sync.session.message.get(sessionID, "missing")).toBeUndefined() @@ -2824,6 +2850,96 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn } }) +test("reconciles admissions and promotions that arrive while pending work hydrates", async () => { + const events = createEventStream() + const sessionID = "session-pending-race" + const messageID = "msg_late_user" + const promotedID = "msg_promoted_user" + const requested = Promise.withResolvers() + const response = Promise.withResolvers() + const calls = createFetch((url) => { + if (url.pathname !== `/api/session/${sessionID}/pending`) return + requested.resolve() + return response.promise + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + const sync = data.session.pending.sync(sessionID) + await requested.promise + emitEvent(events, { + id: "evt_late_admitted", + created: 1, + type: "session.input.admitted", + durable: durable(sessionID), + data: { + sessionID, + inputID: messageID, + input: { type: "user", data: { text: "late" }, delivery: "steer" }, + }, + }) + emitEvent(events, { + id: "evt_promoted_admitted", + created: 2, + type: "session.input.admitted", + durable: durable(sessionID, 1), + data: { + sessionID, + inputID: promotedID, + input: { type: "user", data: { text: "promoted" }, delivery: "steer" }, + }, + }) + emitEvent(events, { + id: "evt_promoted", + created: 3, + type: "session.input.promoted", + durable: durable(sessionID, 2), + data: { sessionID, inputID: promotedID }, + }) + await wait(() => data.session.pending.list(sessionID).length === 1) + response.resolve( + json({ + data: [ + { + admittedSeq: 1, + id: promotedID, + sessionID, + timeCreated: 2, + type: "user", + data: { text: "promoted" }, + delivery: "steer", + }, + ], + }), + ) + await sync + + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID]) + expect(data.session.input.list(sessionID)).toEqual([messageID]) + expect(data.session.message.get(sessionID, messageID)).toMatchObject({ text: "late" }) + expect(data.session.message.get(sessionID, promotedID)).toMatchObject({ text: "promoted" }) + } finally { + app.renderer.destroy() + } +}) + test("skips initial instruction state and projects later updates with their message ID", async () => { const events = createEventStream() const calls = createFetch(undefined, events) diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index e60f79e964fd..0e9aba99a7d4 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -109,6 +109,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Fri, 17 Jul 2026 23:40:51 -0400 Subject: [PATCH 09/18] docs: move experiment records to the quark repo --- docs/design/quark-architecture-review.md | 306 ----------- docs/design/quark-message-content.md | 94 ---- docs/design/quark-performance-model.md | 532 -------------------- docs/design/quark-review-recommendations.md | 137 ----- docs/design/quark-tui-timeline.md | 447 ---------------- packages/quark/THIRD_PARTY_NOTICES.md | 33 ++ packages/quark/package.json | 1 - packages/quark/src/keyed.ts | 2 +- packages/quark/src/reactivity.ts | 475 +++++++++++++++-- packages/quark/test/reactivity.test.ts | 412 ++++++++++++++- packages/tui/src/routes/session/content.ts | 2 +- 11 files changed, 876 insertions(+), 1565 deletions(-) delete mode 100644 docs/design/quark-architecture-review.md delete mode 100644 docs/design/quark-message-content.md delete mode 100644 docs/design/quark-performance-model.md delete mode 100644 docs/design/quark-review-recommendations.md delete mode 100644 docs/design/quark-tui-timeline.md create mode 100644 packages/quark/THIRD_PARTY_NOTICES.md diff --git a/docs/design/quark-architecture-review.md b/docs/design/quark-architecture-review.md deleted file mode 100644 index 28607534deeb..000000000000 --- a/docs/design/quark-architecture-review.md +++ /dev/null @@ -1,306 +0,0 @@ -# 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
Solid Store, unchanged] - D --> R[createSessionRows
row reduction policy] - R -->|"set / update / insert / remove"| K[Keyed<SessionRow, string>
packages/quark/src/keyed.ts] - K -->|"slots — structure channel"| FOR["Solid <For>"] - K -->|"slot — per-row value channel"| UV[useValue adapter
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 { - readonly slots: Readable[]> // fires ONLY on insert/remove/reorder - readonly values: Readable // fires on any current value change - has(key: Key): boolean - get(key: Key): Readable
| undefined - set(values: readonly A[]): void - update(value: A): boolean - insert(value: A, position?: "end" | { before: Key } | { after: Key }): Readable - 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. 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(options: { - readonly key: (value: A) => Key - readonly equivalent?: (left: A, right: A) => boolean -}): Keyed { - const slots = State.make[]>([]) // structure channel: one signal holding the slot array - const byKey = new Map>() // identity → slot address, maintained by set/insert/remove - const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI) - const values = Computed.make((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(readable: Readable): Accessor { - const [value, setValue] = createSignal(readable()) - onCleanup(readable.subscribe((next) => setValue(() => next))) - return value -} -``` - -### The structural cutoff — why `` never even wakes up - -Because `slots` only fires on structural change, a value-only update leaves the outer array **reference-identical**. The `` 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 — 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 - -The compiled `parts` members index contains every visible part identity, -including refs nested inside groups. A duplicate streaming delta dies at -`state.hasMember("parts", 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 `modify`, generated equivalence remains the -final publication cutoff. - -## More Before / After, From the Actual Diff - -### Wiring the renderer: one ``, 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" - - {(row, index) => ( - - )} - -``` - -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" - - {(row, index) => ( - - )} - -``` - -### 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; `` 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. diff --git a/docs/design/quark-message-content.md b/docs/design/quark-message-content.md deleted file mode 100644 index ea6344f6c3ec..000000000000 --- a/docs/design/quark-message-content.md +++ /dev/null @@ -1,94 +0,0 @@ -# Quark-Owned Message Content Slots - -Status: implemented; drive parity pending - -## Summary - -Assistant message content currently lives as unkeyed arrays inside the -DataProvider Solid store. Streaming deltas mutate those arrays through -`produce`, and every consumer that resolves a part re-scans `content` -(`findLast`, `filter(type)[ordinal]`), so per-delta work scales with message -size instead of delta size. - -This experiment changes only content ownership: each assistant message's parts -become one Quark keyed collection with stable per-part slots. Streaming events -already name the slot on the wire — `ordinal` for text and reasoning, `callID` -for tools — so deltas address slots directly instead of being rediscovered. - -Message metadata (finish, usage, retry, error), the event protocol, timeline -rows, and view components' rendering logic remain unchanged. - -The experiment succeeds only if all checks pass: - -1. Existing TUI data and row tests preserve their behavior. -2. Per-delta work is O(1) in message size: one slot publication per delta, - zero structural publications, one markdown recompute scoped to the part. -3. The drive script passes unchanged. - -## Ownership Boundary - -``` -server events - | - v -DataProvider - ├── Solid store: session/message metadata (unchanged) - └── SessionContent: Keyed per assistant message <- this experiment - | | - | slots (structure) | slot (value channel) - v v - part row resolution TextPart / ReasoningPart / ToolPart -``` - -- Part identity: `text:{ordinal}` and `reasoning:{ordinal}` from event - ordinals, tool parts by `callID`. These match the synthetic part IDs the - timeline already uses, so `resolvePart`'s positional bridge disappears. -- Deltas are value-channel publications: `content.modify(key, ...)`. The - parts ``/group structure never re-runs on a delta. -- Part starts are structural insertions; `ended` events publish the - authoritative final value into the same slot. -- Full sync (`message.sync`) seeds each message's collection with `set(parts)` - derived from the fetched payload, assigning ordinals per type in order. -- Revert, session removal, and model-switch refetch drop or reseed collections. -- Cold consumers (transcript export, copy-last-response, message navigation, - permission lookup) read `values()` — plain arrays, same shapes as before. - -## Part Layout - -Tool identity fields are immutable; streamed fields are mutable. The field -diff mask means a tool status change skips text-driven work and vice versa. - -```ts -const PartLayout = Layout.keyedUnion({ - key: Layout.key("partID", Layout.string), - tag: "type", - variants: { - text: Layout.struct({ text: Layout.string }), - reasoning: Layout.struct({ text: Layout.string, state: ..., time: ... }), - tool: Layout.struct({ - id: Layout.immutable(Layout.string), - name: Layout.immutable(Layout.string), - state: ..., time: ..., executed: ..., providerState: ..., - }), - }, -}) -``` - -Store values are the client `SessionMessageAssistant*` content shapes plus the -synthetic `partID`; `values()` strips nothing because consumers already ignore -unknown fields. - -## What Is Not In Scope - -- Message metadata stays in the Solid store. -- No changes to server events or the client package types. -- No lazy or pull-based consumers; slots publish eagerly as today. -- Timeline row ownership is the previous experiment and is unchanged. - -## Measurement - -Instrument with `Keyed.Metrics`: a streaming test drives N deltas into a -message with M existing parts and asserts slot publications == N and -structural publications == parts started, independent of M. Markdown -recompute counts are asserted through the part component render counters in -the existing test harness. diff --git a/docs/design/quark-performance-model.md b/docs/design/quark-performance-model.md deleted file mode 100644 index 3220fd4dba4f..000000000000 --- a/docs/design/quark-performance-model.md +++ /dev/null @@ -1,532 +0,0 @@ -# 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 - -`Layout.collection` compiles identity, equivalence, and indexes once when the -timeline is created. Every row carries a collision-safe primitive `id`. Groups -also retain their immutable origin for message-boundary projection: - -```typescript -const PartRefLayout = Layout.struct({ - messageID: Layout.string, - partID: Layout.string, -}) - -const SessionRowLayout = Layout.keyedUnion({ - key: Layout.key("id", Layout.string), - tag: "type", - variants: { - message: Layout.struct({ messageID: Layout.string }), - "compaction-queued": Layout.struct({ inputID: Layout.string }), - part: Layout.struct({ ref: PartRefLayout }), - group: Layout.union({ - tag: "kind", - variants: { - reasoning: Layout.struct({ - origin: Layout.immutable(PartRefLayout), - refs: Layout.array(PartRefLayout), - completed: Layout.boolean, - }), - exploration: Layout.struct({ - origin: Layout.immutable(PartRefLayout), - refs: Layout.array(PartRefLayout), - pending: Layout.array(PartRefLayout), - completed: Layout.boolean, - }), - }, - }), - "assistant-footer": Layout.struct({ messageID: Layout.string }), - }, -}) - -type PartRef = Layout.Type -type SessionRow = Layout.Type -``` - -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 } -} -``` - -Generated equivalence answers a different question: can consumers distinguish -these two values for the same identity? The layout compares rendered fields, -dispatches through the row and group tags, and ignores immutable `origin`. - -```typescript -const SessionRows = Layout.collection( - SessionRowLayout, - ({ members }) => ({ - parts: members(partIDs), - }), - { backend: "generated" }, -) - -const rows = SessionRows.make() -``` - -The `parts` index contains standalone and grouped part IDs. Duplicate stream -deltas return through `hasMember` before aggregate projection. Arbitrary -replacements derive membership automatically; the hot group-append path applies -the exact one-member delta already known from the event. - -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 `` 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.modify(initial.id, (group) => ({ ...group, completed: true })) - -rows.slots() === structure // true: 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. Look up retained slots in the persistent key map. - 3. For each next value: - a. Reuse the previous slot for its key, or create one. - b. Run generated equivalence over previous and next values. - c. Write only a changed slot. - 4. Publish the outer slot array only if membership or order changed. - 5. Synchronize declared collection indexes. - 6. Flush slot, index, 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` contract. They are also -testable. Quark should reject a violated unique-key law and should have direct -tests for every other law. - -## Layout Compiles Trusted Collection Metadata - -The timeline now uses Quark's trusted in-memory `Layout`. It supports the scope -required by this experiment: - -- primitive, array, struct, discriminated-union, and keyed-union fields; -- immutable fields excluded from rendered equivalence; -- compiled key extraction and closure or generated equivalence; -- imperative multi-value membership and ordered first-match indexes; -- automatic index derivation for arbitrary replacements; -- typed member deltas for event-native mutations that know exact changes. - -Generated nested union comparison measured `1.06x-1.10x` handwritten cost, -versus `1.19x-1.22x` for closure Layout across three runs. Generation occurs -once when `SessionRows` is declared. The older `1.322x` closure result motivated -this backend; it no longer describes the production comparator. - -Automatic membership derivation is linear in an affected row's members. That -is the safe default, but it made repeated append to one growing group roughly -`27x-30x` the handwritten index path. The event already identifies the new -part, so `SessionTimeline.appendPart` supplies a typed one-member delta. Generic -completion and permission repartition retain automatic derivation. - -Numeric field positions, change masks, per-field reactive slots, models, and -columnar storage remain future research. They are not required by the TUI. - -## 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 `has`, `get`, `hasMember`, and `modify` find their target in expected -`O(1)` time. Work is then proportional to the changed row, affected declared -indexes, and any ordered structural array copy. `SessionTimeline` caches the -active group and queued boundary IDs; inserts and removals remain `O(N)` because -the ordered slots array must be copied. Full synchronization and revert rebuilds -remain linear `set` operations. - -## 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 - -cd ../tui -bun run bench:timeline -``` - -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 lower-level `Keyed` benchmark measures event-native updates with -the aggregate `values` channel subscribed, plus adverse workloads. It does not -include `SessionTimeline`, generated row equivalence, or compiled index -maintenance. 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. - -The checked-in `SessionTimeline` benchmark compares the final domain module to -the previous handwritten `Keyed + Set` reducer mechanics and the original Solid -Store `produce` path. Across three runs: - -| Workload | SessionTimeline result | -| ----------------------------------------------------- | ----------------------: | -| Growing reasoning-group append / handwritten Quark | about `1.3x-1.5x` | -| Growing reasoning-group append / Solid `produce` | about `0.013x` | -| Duplicate part in a 1,000-ref group / Solid `produce` | about `0.0004x-0.0005x` | - -The final module pays roughly 30%-50% over the minimal handwritten Quark path -for compiled layout dispatch, domain policy, and maintained index ownership. -It remains roughly 74x-79x faster than the baseline Solid append in this -growing-group stress workload. Duplicate lookup is expected `O(1)` through the -compiled members index; the Solid baseline scans 1,000 refs, producing an -approximately 1,960x-2,500x stress-case difference. These are state-operation -results, not claims about total OpenCode response latency. - -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, key extraction, growing -timeline groups, and duplicate deltas. 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. - -## Deterministic Tests Measure Work, Not Just Time - -The single-binary deterministic event trace counts: - -| 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 through compiled membership index | -| 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. - -Pure timeline tests additionally cover cached queue advancement, out-of-order -promotion, duplicate message/footer idempotence, stable group ownership, and -parity between event-native operations and snapshot reduction. Unique append -uses cached IDs and never reads `state.values()`. - -## 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. diff --git a/docs/design/quark-review-recommendations.md b/docs/design/quark-review-recommendations.md deleted file mode 100644 index c38bfb1c7260..000000000000 --- a/docs/design/quark-review-recommendations.md +++ /dev/null @@ -1,137 +0,0 @@ -# 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 `` 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` 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) diff --git a/docs/design/quark-tui-timeline.md b/docs/design/quark-tui-timeline.md deleted file mode 100644 index 993115f0ea27..000000000000 --- a/docs/design/quark-tui-timeline.md +++ /dev/null @@ -1,447 +0,0 @@ -# Quark-Owned TUI Timeline Rows - -Status: experiment in progress - -## Summary - -The V2 TUI previously stored its rendered session-row list in a Solid Store. -This experiment changes only that owner: `SessionTimeline` stores an ordered -array of stable row slots in a compiled Quark collection. 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. Message content remains in `DataProvider`; -`SessionTimeline` owns a finite imperative index of part IDs for its current -rows. - -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 - -> SessionRowView -``` - -This branch replaces only the second store: - -```text -server events - | - v -DataProvider Solid Store - | - v -createSessionRows adapter - | - v -SessionTimeline - | - v -Layout.collection - | - 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` | Translate DataProvider state and events into timeline operations; own the Solid adapter lifecycle. | -| `packages/tui/src/routes/session/timeline.ts` | Declare row layout, reduce snapshots, cache cursors, and implement timeline domain mutations. | -| `packages/quark/src/layout.ts` | Compile row keys/equivalence and maintain declared imperative collection indexes. | -| `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() -> - | - 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. | - -`SessionTimeline` caches the first queued row ID and the active incomplete group -ID. Full replacement derives both once. Live operations maintain them as work -is queued or promoted. New assistant parts and completed messages insert before -the cached queue boundary; pending user inputs append after queued work; a -running compaction becomes the new boundary. - -`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 calls domain operations backed by the compiled -collection: - -```text -group change -> state.modify(groupID, update) -new row -> state.insert(row, { before }) -removed footer -> state.remove(row.id) -full sync / revert -> state.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. - -Message and footer duplicates use the keyed map. Part duplicates use the -compiled `parts` members index and return before aggregate projection. Unique -group appends use cached IDs and do not read `state.values()`. The append event -also supplies the exact one-member index delta, avoiding a scan of the growing -group; arbitrary replacements retain automatic index derivation. - -## `Keyed` Preserves Row Identity - -A full rebuild creates fresh row values, but Solid `` 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. `` therefore receives stable Quark slots rather than row values. - -`Keyed.set(nextRows)` preserves ownership through its persistent key-to-slot -map: - -```text -1. Validate the next keys. -2. Find each retained slot through the persistent map. -3. Update that slot only when generated row equivalence reports a change. -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. `SessionRowLayout` declares that key and generates discriminated-union -equivalence. Group `origin` is immutable metadata; `refs`, `pending`, and -`completed` participate in equivalence. 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 `` 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 for bulk permission repartitioning and snapshot/test -consumers. -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. -The lazy aggregate is read by permission repartitioning and snapshot/test -consumers, not by unique append paths. - -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 | -| Active group / queue lookup | Expected `O(1)` | None | -| Queue boundary advancement | `O(R)` | None | -| Group extension | `O(G)` | One copied group array; `O(1)` index delta | -| Structural insertion/removal | `O(R)` | One structural slots array | -| Duplicate part append | `O(1)` | One key string | -| Duplicate message/footer | `O(1)` | One key string | -| 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. `Layout.collection` maintains every visible part ID, -including refs nested inside groups. Full replacement rebuilds the finite index; -inserts and arbitrary modifications derive membership automatically; the hot -group append applies one typed member delta. Duplicate streaming deltas return -before reading the lazy aggregate or scanning rows. - -## 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. - -Seven baseline/fork behavior 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 | -| 7, timeline extraction | 973 ms | 825 ms | 0.848x | - -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 | 274 pass, 1 skip | -| Focused data and row tests | 55 pass | -| Vendored Quark tests | 24 pass | -| Shared drive scripts typecheck | Pass | -| Shared Drive behavior | Seven 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 | - -## Reactive Query Caches Remain Outside This Phase - -The timeline's imperative members index is finite: entries follow current rows -and are removed synchronously. It is not a reactive query cache and renderers -do not subscribe to it. - -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. diff --git a/packages/quark/THIRD_PARTY_NOTICES.md b/packages/quark/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000000..b02efdc85823 --- /dev/null +++ b/packages/quark/THIRD_PARTY_NOTICES.md @@ -0,0 +1,33 @@ +# Third-Party Notices + +## alien-signals + +The internal reactive kernel in `src/reactivity.ts` adapts the dependency +graph algorithm of [alien-signals](https://github.com/stackblitz/alien-signals) +3.2.1: the intrusive doubly-linked dependency and subscriber links with +versioned in-place reuse, and the iterative `propagate` and `checkDirty` +graph walks. alien-signals is not a runtime dependency of Quark. + +``` +MIT License + +Copyright (c) 2024-present Johnson Chu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/packages/quark/package.json b/packages/quark/package.json index 1548fcbb5317..bd1ae13ccfac 100644 --- a/packages/quark/package.json +++ b/packages/quark/package.json @@ -14,7 +14,6 @@ "test": "bun --conditions=browser test" }, "dependencies": { - "alien-signals": "3.2.1", "solid-js": "catalog:" } } diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts index 32040703a54c..575aa196bb01 100644 --- a/packages/quark/src/keyed.ts +++ b/packages/quark/src/keyed.ts @@ -133,7 +133,7 @@ export namespace Keyed { function publish(slot: Writable, value: A) { const current = slot() - // alien-signals uses SameValueZero-compatible identity for primitive writes. + // The reactive kernel uses strict identity for primitive writes. if (current === value || equivalent(current, value)) { if (options.metrics) options.metrics.equivalenceSuppressions++ return false diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts index d127bf52e99e..7bea77e94bd3 100644 --- a/packages/quark/src/reactivity.ts +++ b/packages/quark/src/reactivity.ts @@ -1,5 +1,3 @@ -import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals" - export interface Readable { (): A subscribe(listener: (value: A) => void): () => void @@ -10,57 +8,466 @@ export interface Writable extends Readable { update(f: (value: A) => A): void } -function subscribe(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(initial: A): Writable { - const state = signal(initial) - const read = (() => state()) as Writable - read.set = (value) => state(value) + const node: StateNode = { + flags: Flags.Mutable, + value: initial, + pending: initial, + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + } + const read = (() => readState(node)) as Writable + read.set = (value) => writeState(node, value) read.update = (f) => { - // Read untracked: calling update inside an effect must not make the - // effect depend on (and re-trigger from) this signal. - const active = setActiveSub() - const current = state() - setActiveSub(active) - state(f(current)) + // Read untracked: calling update inside a tracked evaluation must not + // make the caller depend on (and re-trigger from) this state. + const previous = swapActiveSub(undefined) + try { + writeState(node, f(readState(node))) + } finally { + activeSub = previous + } } - read.subscribe = (listener) => subscribe(read, listener) + read.subscribe = (listener) => subscribeNode(node, read, listener) return read } } export namespace Computed { export function make(evaluate: (previous: A | undefined) => A): Readable { - const value = computed(evaluate) - const read = (() => value()) as Readable - read.subscribe = (listener) => subscribe(read, listener) + const node: ComputedNode = { + flags: Flags.None, + value: undefined, + evaluate, + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + } + const read = (() => readComputed(node)) as Readable + read.subscribe = (listener) => subscribeNode(node, read, listener) return read } } export namespace Transaction { export function run(f: () => A): A { - startBatch() + batchDepth++ try { return f() } finally { - endBatch() + if (!--batchDepth) flush() + } + } +} + +// Internal reactive kernel. +// +// The dependency graph representation (intrusive doubly-linked dependency and +// subscriber links with versioned in-place reuse) and the iterative +// `propagate`/`checkDirty` walks are adapted from alien-signals 3.2.1 +// (https://github.com/stackblitz/alien-signals, MIT). See +// THIRD_PARTY_NOTICES.md. Quark departs from the reference in three ways: +// subscribers are fixed single-dependency watchers instead of general +// effects, orphaned computeds are detached with an iterative work queue +// instead of recursive unwatch callbacks, and reading a computed that is +// currently evaluating throws a stable cycle error instead of looping +// (stackblitz/alien-signals#118, #123). + +const enum Flags { + None = 0, + /** The node can produce a new value: states always, computeds once evaluated. */ + Mutable = 1, + /** The node is a subscription watcher delivering values to a listener. */ + Watching = 2, + /** The computed is currently evaluating; reading it again is a cycle. */ + Computing = 4, + /** The watcher is queued for the next flush. */ + Queued = 8, + /** The node's value is known stale. */ + Dirty = 16, + /** The node's value is possibly stale pending dependency revalidation. */ + Pending = 32, +} + +interface ReactiveNode { + flags: Flags + deps?: Link + depsTail?: Link + subs?: Link + subsTail?: Link +} + +interface StateNode extends ReactiveNode { + value: A + pending: A +} + +interface ComputedNode extends ReactiveNode { + value: A | undefined + evaluate: (previous: A | undefined) => A +} + +interface WatcherNode extends ReactiveNode { + read: () => unknown + listener: (value: unknown) => void +} + +interface Link { + version: number + dep: ReactiveNode + sub: ReactiveNode + prevSub: Link | undefined + nextSub: Link | undefined + prevDep: Link | undefined + nextDep: Link | undefined +} + +interface Stack { + value: A + prev: Stack | undefined +} + +let activeSub: ReactiveNode | undefined +let batchDepth = 0 +let version = 0 +let flushIndex = 0 +let queueLength = 0 +const queue: Array = [] +const orphans: Array = [] + +function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined { + const previous = activeSub + activeSub = sub + return previous +} + +function readState(node: StateNode): A { + if (node.flags & Flags.Dirty && updateState(node) && node.subs !== undefined) { + shallowPropagate(node.subs) + } + if (activeSub !== undefined) link(node, activeSub, version) + return node.value +} + +function writeState(node: StateNode, value: A): void { + if (node.pending === (node.pending = value)) return + node.flags = Flags.Mutable | Flags.Dirty + if (node.subs !== undefined) { + propagate(node.subs) + if (!batchDepth) flush() + } +} + +function readComputed(node: ComputedNode): A { + const flags = node.flags + if (flags & Flags.Computing) { + throw new Error("Reactive cycle detected: a computed depends on its own value") + } + if ( + flags & Flags.Dirty || + (flags & Flags.Pending && + (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) || + !flags + ) { + if (updateComputed(node) && node.subs !== undefined) { + shallowPropagate(node.subs) + } + } + if (activeSub !== undefined) link(node, activeSub, version) + return node.value! +} + +function updateState(node: StateNode): boolean { + node.flags = Flags.Mutable + return node.value !== (node.value = node.pending) +} + +function updateComputed(node: ComputedNode): boolean { + node.depsTail = undefined + node.flags = Flags.Mutable | Flags.Computing + const previous = swapActiveSub(node) + version++ + let completed = false + try { + const oldValue = node.value + const changed = oldValue !== (node.value = node.evaluate(oldValue)) + completed = true + return changed + } finally { + activeSub = previous + node.flags &= ~Flags.Computing + // A throwing evaluation stays dirty so the next read retries instead of + // serving a stale value with no dependency links. + if (!completed) node.flags |= Flags.Dirty + purgeDeps(node) + } +} + +function subscribeNode(node: ReactiveNode, read: () => A, listener: (value: A) => void): () => void { + // Evaluate untracked before linking so a throwing computed leaves no + // partially initialized watcher behind. + const previous = swapActiveSub(undefined) + try { + read() + } finally { + activeSub = previous + } + const watcher: WatcherNode = { + flags: Flags.Watching, + read, + listener: listener as (value: unknown) => void, + deps: undefined, + depsTail: undefined, + } + link(node, watcher, ++version) + return () => { + if (!(watcher.flags & Flags.Watching)) return + watcher.flags &= ~(Flags.Watching | Flags.Dirty | Flags.Pending) + unlink(watcher.deps!, watcher) + drainOrphans() + } +} + +function runWatcher(watcher: WatcherNode): void { + const flags = watcher.flags + watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending) + if (!(flags & Flags.Watching)) return + const dirty = + !!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher)) + // Revalidation runs user code in computed evaluations, which may dispose + // this watcher; a disposed watcher must not deliver. + if (!dirty || !(watcher.flags & Flags.Watching)) return + // Listener reads stay untracked and listeners may dispose subscriptions, + // including their own. + const previous = swapActiveSub(undefined) + try { + watcher.listener(watcher.read()) + } finally { + activeSub = previous + } +} + +function flush(): void { + try { + while (flushIndex < queueLength) { + const watcher = queue[flushIndex]! + queue[flushIndex++] = undefined + runWatcher(watcher) + } + } finally { + // A throwing listener aborts this flush; the remaining watchers keep + // their Dirty/Pending flags and requeue on the next propagation. + while (flushIndex < queueLength) { + const watcher = queue[flushIndex]! + queue[flushIndex++] = undefined + watcher.flags &= ~Flags.Queued + } + flushIndex = 0 + queueLength = 0 + } +} + +function enqueue(watcher: WatcherNode): void { + watcher.flags |= Flags.Queued + queue[queueLength++] = watcher +} + +function link(dep: ReactiveNode, sub: ReactiveNode, linkVersion: number): void { + const prevDep = sub.depsTail + if (prevDep !== undefined && prevDep.dep === dep) return + const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps + if (nextDep !== undefined && nextDep.dep === dep) { + nextDep.version = linkVersion + sub.depsTail = nextDep + return + } + const prevSub = dep.subsTail + if (prevSub !== undefined && prevSub.version === linkVersion && prevSub.sub === sub) return + const newLink: Link = { + version: linkVersion, + dep, + sub, + prevDep, + nextDep, + prevSub, + nextSub: undefined, + } + sub.depsTail = newLink + dep.subsTail = newLink + if (nextDep !== undefined) nextDep.prevDep = newLink + if (prevDep !== undefined) prevDep.nextDep = newLink + else sub.deps = newLink + if (prevSub !== undefined) prevSub.nextSub = newLink + else dep.subs = newLink +} + +function unlink(current: Link, sub: ReactiveNode): Link | undefined { + const dep = current.dep + const prevDep = current.prevDep + const nextDep = current.nextDep + const nextSub = current.nextSub + const prevSub = current.prevSub + if (nextDep !== undefined) nextDep.prevDep = prevDep + else sub.depsTail = prevDep + if (prevDep !== undefined) prevDep.nextDep = nextDep + else sub.deps = nextDep + if (nextSub !== undefined) nextSub.prevSub = prevSub + else dep.subsTail = prevSub + if (prevSub !== undefined) prevSub.nextSub = nextSub + else if ((dep.subs = nextSub) === undefined && dep.deps !== undefined) orphans.push(dep) + return nextDep +} + +// Detaches computeds that lost their last subscriber. Iterative on purpose: +// a recursive unwatch cascade overflows the stack on deep chains. Only nodes +// with dependencies enter the queue (states and dep-less computeds have +// nothing to detach). As in the reference, a computed that is read but never +// subscribed stays linked to its sources until they are collected; create +// long-lived computeds rather than per-operation ones. +function drainOrphans(): void { + while (orphans.length > 0) { + const node = orphans.pop()! + if (!("evaluate" in node) || node.depsTail === undefined) continue + node.flags = Flags.Mutable | Flags.Dirty + let current = node.depsTail as Link | undefined + while (current !== undefined) { + const prev = current.prevDep + unlink(current, node) + current = prev } } } + +function purgeDeps(sub: ReactiveNode): void { + const depsTail = sub.depsTail + let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps + while (dep !== undefined) { + dep = unlink(dep, sub) + } + drainOrphans() +} + +function propagate(current: Link): void { + let next = current.nextSub + let stack: Stack | undefined + + top: do { + const sub = current.sub + const flags = sub.flags + const unmarked = !(flags & (Flags.Dirty | Flags.Pending)) + if (unmarked) sub.flags = flags | Flags.Pending + if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode) + + if (unmarked && flags & Flags.Mutable) { + const subSubs = sub.subs + if (subSubs !== undefined) { + current = subSubs + const nextSub = subSubs.nextSub + if (nextSub !== undefined) { + stack = { value: next, prev: stack } + next = nextSub + } + continue + } + } + + if (next !== undefined) { + current = next + next = current.nextSub + continue + } + + while (stack !== undefined) { + const continuation = stack.value + stack = stack.prev + if (continuation !== undefined) { + current = continuation + next = current.nextSub + continue top + } + } + + break + } while (true) +} + +function shallowPropagate(current: Link): void { + let iterator: Link | undefined = current + do { + const sub = iterator.sub + const flags = sub.flags + if ((flags & (Flags.Pending | Flags.Dirty)) === Flags.Pending) { + sub.flags = flags | Flags.Dirty + if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode) + } + iterator = iterator.nextSub + } while (iterator !== undefined) +} + +function updateNode(node: ReactiveNode): boolean { + if ("evaluate" in node) return updateComputed(node as ComputedNode) + return updateState(node as StateNode) +} + +function checkDirty(current: Link, sub: ReactiveNode): boolean { + let stack: Stack | undefined + let checkDepth = 0 + let dirty = false + + top: do { + const dep = current.dep + const flags = dep.flags + + if (sub.flags & Flags.Dirty) { + dirty = true + } else if ((flags & (Flags.Mutable | Flags.Dirty)) === (Flags.Mutable | Flags.Dirty)) { + const subs = dep.subs! + if (updateNode(dep)) { + if (subs.nextSub !== undefined) shallowPropagate(subs) + dirty = true + } + } else if ((flags & (Flags.Mutable | Flags.Pending)) === (Flags.Mutable | Flags.Pending)) { + stack = { value: current, prev: stack } + current = dep.deps! + sub = dep + checkDepth++ + continue + } + + if (!dirty) { + const nextDep = current.nextDep + if (nextDep !== undefined) { + current = nextDep + continue + } + } + + while (checkDepth--) { + current = stack!.value + stack = stack!.prev + if (dirty) { + const subs = sub.subs! + if (updateNode(sub)) { + if (subs.nextSub !== undefined) shallowPropagate(subs) + sub = current.sub + continue + } + dirty = false + } else { + sub.flags &= ~Flags.Pending + } + sub = current.sub + const nextDep = current.nextDep + if (nextDep !== undefined) { + current = nextDep + continue top + } + } + + return dirty + } while (true) +} diff --git a/packages/quark/test/reactivity.test.ts b/packages/quark/test/reactivity.test.ts index 66c1f479bcdb..1c3b1e22242c 100644 --- a/packages/quark/test/reactivity.test.ts +++ b/packages/quark/test/reactivity.test.ts @@ -1,13 +1,110 @@ import { describe, expect, it } from "bun:test" -import { createEffect, createRoot } from "solid-js" -import { State } from "../src" -import { useValue } from "../src/solid" +import { Computed, State, Transaction, type Readable } from "../src/reactivity" + +describe("reactivity", () => { + it("keeps computed values lazy", () => { + const source = State.make(1) + let evaluations = 0 + const doubled = Computed.make(() => { + evaluations++ + return source() * 2 + }) + + expect(evaluations).toBe(0) + expect(doubled()).toBe(2) + expect(doubled()).toBe(2) + expect(evaluations).toBe(1) + + source.set(2) + expect(evaluations).toBe(1) + expect(doubled()).toBe(4) + expect(evaluations).toBe(2) + }) + + it("propagates a diamond without glitches", () => { + const source = State.make(1) + const left = Computed.make(() => source() * 2) + const right = Computed.make(() => source() * 3) + const total = Computed.make(() => left() + right()) + const values: Array = [] + const dispose = total.subscribe((value) => values.push(value)) + + source.set(2) + + expect(values).toEqual([10]) + dispose() + }) + + it("tracks dynamic dependencies", () => { + const enabled = State.make(true) + const left = State.make(1) + const right = State.make(10) + const selected = Computed.make(() => (enabled() ? left() : right())) + const values: Array = [] + const dispose = selected.subscribe((value) => values.push(value)) + + left.set(2) + enabled.set(false) + left.set(3) + right.set(11) + + expect(values).toEqual([2, 10, 11]) + dispose() + }) + + it("batches transactions", () => { + const left = State.make(1) + const right = State.make(2) + const total = Computed.make(() => left() + right()) + const values: Array = [] + const dispose = total.subscribe((value) => values.push(value)) + + Transaction.run(() => { + left.set(2) + right.set(3) + }) + + expect(values).toEqual([5]) + dispose() + }) + + it("cuts off unchanged derived values", () => { + const source = State.make(1) + let parityEvaluations = 0 + let labelEvaluations = 0 + const parity = Computed.make(() => { + parityEvaluations++ + return source() % 2 + }) + const label = Computed.make(() => { + labelEvaluations++ + return parity() === 0 ? "even" : "odd" + }) + const dispose = label.subscribe(() => {}) + + source.set(3) + + expect(parityEvaluations).toBe(2) + expect(labelEvaluations).toBe(1) + dispose() + }) + + it("disposes subscriptions", () => { + const source = State.make(1) + const values: Array = [] + const dispose = source.subscribe((value) => values.push(value)) + + source.set(2) + dispose() + source.set(3) + + expect(values).toEqual([2]) + }) -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 values: Array = [] const dispose = source.subscribe((value) => { unrelated() values.push(value) @@ -20,20 +117,311 @@ describe("Quark", () => { dispose() }) - it("bridges values into a Solid owner and disposes with it", () => { + it("does not track the state read by update", () => { + const trigger = State.make(0) + const updated = State.make(0) + let runs = 0 + const dispose = trigger.subscribe(() => { + runs++ + updated.update((value) => value + 1) + }) + + trigger.set(1) + updated.set(10) + + expect(runs).toBe(1) + expect(updated()).toBe(10) + dispose() + }) + + it("delivers the previous value to computed evaluation", () => { + const source = State.make(1) + const previousValues: Array = [] + const running = Computed.make((previous) => { + previousValues.push(previous) + return (previous ?? 0) + source() + }) + + expect(running()).toBe(1) + source.set(2) + expect(running()).toBe(3) + source.set(3) + expect(running()).toBe(6) + expect(previousValues).toEqual([undefined, 1, 3]) + }) + + it("detaches and reattaches dynamic dependencies", () => { + const enabled = State.make(true) + const left = State.make(1) + const right = State.make(10) + let evaluations = 0 + const selected = Computed.make(() => { + evaluations++ + return enabled() ? left() : right() + }) + const dispose = selected.subscribe(() => {}) + + expect(evaluations).toBe(1) + enabled.set(false) + expect(evaluations).toBe(2) + + // Detached: left writes must not re-evaluate the computed. + left.set(2) + left.set(3) + expect(evaluations).toBe(2) + + // Reattached: left writes re-evaluate, right writes no longer do. + enabled.set(true) + expect(evaluations).toBe(3) + expect(selected()).toBe(3) + right.set(11) + expect(evaluations).toBe(3) + left.set(4) + expect(evaluations).toBe(4) + expect(selected()).toBe(4) + dispose() + }) + + it("notifies a diamond subscriber exactly once per write", () => { + const source = State.make(1) + const left = Computed.make(() => source() * 2) + const right = Computed.make(() => source() * 3) + const total = Computed.make(() => left() + right()) + let notifications = 0 + const dispose = total.subscribe(() => notifications++) + + source.set(2) + source.set(3) + + expect(notifications).toBe(2) + dispose() + }) + + it("batches nested transactions until the outermost ends", () => { + const left = State.make(1) + const right = State.make(2) + const total = Computed.make(() => left() + right()) + const values: Array = [] + const dispose = total.subscribe((value) => values.push(value)) + + Transaction.run(() => { + left.set(2) + Transaction.run(() => { + right.set(3) + }) + expect(values).toEqual([]) + left.set(3) + }) + + expect(values).toEqual([6]) + dispose() + }) + + it("restores batch state when a transaction throws", () => { + const source = State.make(1) + const values: Array = [] + const dispose = source.subscribe((value) => values.push(value)) + + expect(() => + Transaction.run(() => { + source.set(2) + throw new Error("boom") + }), + ).toThrow("boom") + + // The write that happened before the throw still flushes once the + // transaction unwinds, and later writes are not batched. + expect(values).toEqual([2]) + source.set(3) + expect(values).toEqual([2, 3]) + dispose() + }) + + it("supports disposing another subscription during notification", () => { const source = State.make(1) - const values: number[] = [] + const first: Array = [] + const second: Array = [] + let disposeSecond = () => {} + const disposeFirst = source.subscribe((value) => { + first.push(value) + disposeSecond() + }) + disposeSecond = source.subscribe((value) => second.push(value)) + + source.set(2) + source.set(3) + + expect(first).toEqual([2, 3]) + expect(second).toEqual([]) + disposeFirst() + }) + + it("does not deliver to a watcher disposed during revalidation", () => { + const source = State.make(0) let dispose = () => {} - createRoot((rootDispose) => { - dispose = rootDispose - const value = useValue(source) - createEffect(() => values.push(value())) + const gate = Computed.make(() => { + const value = source() + if (value > 0) dispose() + return value + }) + const values: Array = [] + dispose = gate.subscribe((value) => values.push(value)) + + // Revalidating the watcher re-evaluates gate, whose evaluation disposes + // the subscription before the value could be delivered. + source.set(1) + source.set(2) + + expect(values).toEqual([]) + }) + + it("supports a subscription disposing itself during notification", () => { + const source = State.make(1) + const values: Array = [] + let dispose = () => {} + dispose = source.subscribe((value) => { + values.push(value) + dispose() + }) + + source.set(2) + source.set(3) + + expect(values).toEqual([2]) + }) + + it("does not track State.update reading its own value", () => { + const counter = State.make(0) + const source = State.make(1) + let evaluations = 0 + const tracked = Computed.make(() => { + evaluations++ + counter.update((value) => value) + return source() + }) + const dispose = tracked.subscribe(() => {}) + + expect(evaluations).toBe(1) + counter.set(5) + expect(evaluations).toBe(1) + source.set(2) + expect(evaluations).toBe(2) + dispose() + }) + + it("recovers tracking after a computed evaluation throws", () => { + const shouldThrow = State.make(true) + const source = State.make(1) + const throwing = Computed.make(() => { + if (shouldThrow()) throw new Error("computed boom") + return source() }) + expect(() => throwing()).toThrow("computed boom") + + // The failed evaluation must restore the active observer so unrelated + // graphs keep tracking correctly afterwards. + const other = State.make(1) + const doubled = Computed.make(() => other() * 2) + const values: Array = [] + const dispose = doubled.subscribe((value) => values.push(value)) + other.set(2) + expect(values).toEqual([4]) + dispose() + + shouldThrow.set(false) + expect(throwing()).toBe(1) source.set(2) + expect(throwing()).toBe(2) + }) + + it("keeps notifying other subscribers after a listener throws", () => { + const source = State.make(1) + const values: Array = [] + const disposeThrowing = source.subscribe(() => { + throw new Error("listener boom") + }) + const dispose = source.subscribe((value) => values.push(value)) + + expect(() => source.set(2)).toThrow("listener boom") + disposeThrowing() + source.set(3) + + expect(values).toContain(3) dispose() + }) + + it("leaves no dependency links behind when subscription initialization fails", () => { + const source = State.make(1) + const throwing = Computed.make(() => { + if (source() === 1) throw new Error("init boom") + return source() + }) + const values: Array = [] + + expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom") + + // The failed subscription must not stay linked to the graph. + source.set(2) source.set(3) + expect(values).toEqual([]) + + // The computed itself remains usable. + expect(throwing()).toBe(3) + }) + + it("propagates deep chains without recursive stack growth", () => { + // A cold pull of an unevaluated chain necessarily nests user getter + // frames, so each layer is evaluated as it is built. The kernel-owned + // paths under test are dirty propagation and revalidation, which must + // walk the full depth iteratively. + const depth = 100_000 + const source = State.make(0) + const chain = Array.from({ length: depth }).reduce>((current) => { + const next = Computed.make(() => current() + 1) + next() + return next + }, source) + + expect(chain()).toBe(depth) + const values: Array = [] + const dispose = chain.subscribe((value) => values.push(value)) + source.set(1) + expect(values).toEqual([depth + 1]) + dispose() + }) + + it("propagates wide fan-out without recursive stack growth", () => { + const width = 50_000 + const source = State.make(0) + const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1)) + const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0)) + let sink = 0 + const dispose = total.subscribe((value) => { + sink = value + }) + + source.set(1) + expect(sink).toBe(width * 2) + dispose() + }) + + it("fails deterministically on cyclic computed dependencies", () => { + // Regression for stackblitz/alien-signals#123: mutually dependent + // computed graphs must throw a stable error instead of looping or + // exhausting memory. + const fieldA = State.make(false) + const fieldB = State.make(false) + const a: Readable = Computed.make(() => (b() !== true ? fieldA() : null)) + const b: Readable = Computed.make(() => (a() !== true ? fieldB() : null)) + + // Every read that reaches the cycle fails the same way; a failed + // evaluation stays dirty and retries instead of serving a stale value. + expect(() => a()).toThrow(/cycle/i) + expect(() => b()).toThrow(/cycle/i) + + fieldA.set(true) - expect(values).toEqual([1, 2]) + expect(() => a()).toThrow(/cycle/i) }) }) diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts index 8692879503b7..bd454f84afd0 100644 --- a/packages/tui/src/routes/session/content.ts +++ b/packages/tui/src/routes/session/content.ts @@ -7,7 +7,7 @@ import { Keyed, Layout } from "effect-quark" * Streaming events name their part on the wire (text/reasoning by ordinal, * tools by callID); each assistant message owns one keyed collection so a * delta publishes exactly one slot instead of reconciling a content array. - * See docs/design/quark-message-content.md. + * Design record: quark repo, docs/experiments/quark-message-content.md. */ export namespace SessionContent { type ContentPart = SessionMessageAssistant["content"][number] From 95ce14369a8dd7e4c45de8b93e9e99fa8ece36c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:42:22 -0400 Subject: [PATCH 10/18] chore(quark): insource reactive kernel from standalone Sync the vendored packages/quark reactivity with the standalone repo's internal kernel (quark 2a68785) and drop the alien-signals dependency. Kernel algorithm adapted from alien-signals 3.2.1 with MIT attribution in packages/quark/THIRD_PARTY_NOTICES.md. --- bun.lock | 3 --- 1 file changed, 3 deletions(-) diff --git a/bun.lock b/bun.lock index 9b84a91cb6b9..2fe84ea4c51b 100644 --- a/bun.lock +++ b/bun.lock @@ -782,7 +782,6 @@ "name": "effect-quark", "version": "0.0.0", "dependencies": { - "alien-signals": "3.2.1", "solid-js": "catalog:", }, }, @@ -3486,8 +3485,6 @@ "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], - "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=="], From 5e3eb0d00ce684df465df0fc260c53d5ef3fec92 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:45:56 -0400 Subject: [PATCH 11/18] chore(quark): sync reactive kernel and rename package to @opencode-ai/quark --- packages/quark/package.json | 2 +- packages/quark/src/layout.ts | 232 +++++++++----------- packages/quark/src/solid.ts | 6 +- packages/tui/bench/session-timeline.ts | 2 +- packages/tui/package.json | 2 +- packages/tui/src/routes/session/content.ts | 2 +- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/src/routes/session/rows.ts | 4 +- packages/tui/src/routes/session/timeline.ts | 2 +- 9 files changed, 119 insertions(+), 135 deletions(-) diff --git a/packages/quark/package.json b/packages/quark/package.json index bd1ae13ccfac..642d1593d68e 100644 --- a/packages/quark/package.json +++ b/packages/quark/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "effect-quark", + "name": "@opencode-ai/quark", "version": "0.0.0", "private": true, "type": "module", diff --git a/packages/quark/src/layout.ts b/packages/quark/src/layout.ts index 85677be2c810..662006f09eac 100644 --- a/packages/quark/src/layout.ts +++ b/packages/quark/src/layout.ts @@ -74,13 +74,17 @@ export namespace Layout { make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed } + type IndexField = A extends unknown ? keyof A : never + export interface MembersIndex { readonly type: "members" + readonly fields?: readonly IndexField[] readonly extract: (value: A) => Iterable } export interface FirstIndex { readonly type: "first" + readonly fields?: readonly IndexField[] readonly matches: (value: A) => boolean } @@ -114,7 +118,7 @@ export namespace Layout { /** * Comparator backend. "generated" (the default) compiles one specialized - * Function per plan; "closure" is the compatibility fallback for + * Function per plan operation; "closure" is the compatibility fallback for * environments that forbid runtime code generation (CSP). */ export interface CompileOptions { @@ -123,7 +127,9 @@ export namespace Layout { export interface IndexBuilder { members(extract: (value: A) => Iterable): MembersIndex + members(fields: readonly IndexField[], extract: (value: A) => Iterable): MembersIndex first(matches: (value: A) => boolean): FirstIndex + first(fields: readonly IndexField[], matches: (value: A) => boolean): FirstIndex } export interface Collection> extends Keyed.Keyed { @@ -270,8 +276,14 @@ export namespace Layout { export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown { const plan = compile(input as never, options) as Plan const definitions = (define as (index: IndexBuilder) => Indexes)({ - members: (extract) => ({ type: "members", extract }), - first: (matches) => ({ type: "first", matches }), + members: ((fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable), extract?: (value: unknown) => Iterable) => + typeof fieldsOrExtract === "function" + ? { type: "members", extract: fieldsOrExtract } + : { type: "members", fields: fieldsOrExtract, extract: extract! }) as IndexBuilder["members"], + first: ((fieldsOrMatches: readonly PropertyKey[] | ((value: unknown) => boolean), matches?: (value: unknown) => boolean) => + typeof fieldsOrMatches === "function" + ? { type: "first", matches: fieldsOrMatches } + : { type: "first", fields: fieldsOrMatches, matches: matches! }) as IndexBuilder["first"], }) return { ...plan, @@ -314,52 +326,22 @@ export namespace Layout { ): Collection { // `pending` hands a diff the collection already computed for a staged // mutation to the comparator, so equivalence costs one comparison, not two. - let pending: number | ReadonlyMap | undefined + let pending: { readonly previous: A; readonly next: A; readonly mask: number } | undefined const values = Keyed.make({ key: plan.keyOf, equivalent(left, right) { - const mask = typeof pending === "number" ? pending : pending?.get(plan.keyOf(left)) - return (mask ?? plan.diff(left, right)) === 0 + if (pending && pending.previous === left && pending.next === right) { + const mask = pending.mask + pending = undefined + return mask === 0 + } + return plan.diff(left, right) === 0 }, metrics: options?.metrics, }) - // Projection dependency tracker: extract/matches run against a proxy that - // records which top-level fields they read, as a bitmask aligned with - // plan.diff. Enumerating keys or reading an unknown property is - // conservative (all bits). Soundness: a pure projection re-reads a field - // only if the fields it previously read are unchanged or it is re-run. - let trackTarget: Record | undefined - let trackMask = 0 - const trackProxy = new Proxy({} as Record, { - get(_, property) { - trackMask |= plan.bits.get(property) ?? -1 - return trackTarget![property] - }, - has(_, property) { - trackMask |= plan.bits.get(property) ?? -1 - return property in trackTarget! - }, - ownKeys() { - trackMask = -1 - return Reflect.ownKeys(trackTarget!) - }, - getOwnPropertyDescriptor(_, property) { - trackMask = -1 - const descriptor = Reflect.getOwnPropertyDescriptor(trackTarget!, property) - return descriptor && { ...descriptor, configurable: true } - }, - }) - function tracked(value: A, f: (value: A) => T) { - trackTarget = value as Record - trackMask = 0 - try { - const result = f(trackProxy as A) - return { result, reads: trackMask } - } finally { - trackTarget = undefined - trackMask = 0 - } + function dependencyMask(fields: readonly PropertyKey[] | undefined) { + return fields?.reduce((mask, field) => mask | (plan.bits.get(normalizeName(field)) ?? -1), 0) ?? -1 } // Each index entry stages user-code projections before any state mutation @@ -369,20 +351,23 @@ export namespace Layout { type MemberChange = { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] } interface Entry { readonly name: PropertyKey - reads(key: Key): number | undefined + readonly reads: number stage(key: Key, value: A, mode: "add" | "replace"): Commit applyDelta?(key: Key, change: MemberChange): void remove(key: Key, slot?: Readable): void - afterPlacement(slot: Readable): void - afterRebuild(): void + afterPlacement?(slot: Readable): void + afterRebuild?(): void member?(candidate: unknown): boolean firstSlot?(): Readable | undefined } - function membersEntry(name: PropertyKey, extract: (value: A) => Iterable): Entry { + function membersEntry( + name: PropertyKey, + fields: readonly PropertyKey[] | undefined, + extract: (value: A) => Iterable, + ): Entry { const counts = new Map() const byKey = new Map; readonly members: Set }>() - const readsByKey = new Map() function adjust(member: unknown, amount: 1 | -1) { const count = (counts.get(member) ?? 0) + amount @@ -395,24 +380,18 @@ export namespace Layout { return { name, - reads: (key) => readsByKey.get(key), + reads: dependencyMask(fields), stage(key, value) { const previous = byKey.get(key) - const trackedMembers = tracked(value, (candidate) => { - const source = extract(candidate) - return { - source, - members: source === previous?.source ? previous.members : new Set(source), - } - }) + const source = extract(value) + const members = source === previous?.source ? previous.members : new Set(source) return () => { - readsByKey.set(key, trackedMembers.reads) - if (!previous) trackedMembers.result.members.forEach((member) => adjust(member, 1)) - if (previous && previous.members !== trackedMembers.result.members) { - trackedMembers.result.members.forEach((member) => !previous.members.has(member) && adjust(member, 1)) - previous.members.forEach((member) => !trackedMembers.result.members.has(member) && adjust(member, -1)) + if (!previous) members.forEach((member) => adjust(member, 1)) + if (previous && previous.members !== members) { + members.forEach((member) => !previous.members.has(member) && adjust(member, 1)) + previous.members.forEach((member) => !members.has(member) && adjust(member, -1)) } - byKey.set(key, trackedMembers.result) + byKey.set(key, { source, members }) } }, applyDelta(key, change) { @@ -425,25 +404,23 @@ export namespace Layout { members.add(member) adjust(member, 1) }) - // The set was mutated in place: poison the source-identity check and - // drop observed reads so the next non-delta projection recomputes. + // The set was mutated in place, so poison the source-identity check. byKey.set(key, { source: members, members }) - readsByKey.delete(key) }, remove(key) { byKey.get(key)?.members.forEach((member) => adjust(member, -1)) byKey.delete(key) - readsByKey.delete(key) }, - afterPlacement() {}, - afterRebuild() {}, member: (candidate) => counts.has(candidate), } } - function firstEntry(name: PropertyKey, matches: (value: A) => boolean): Entry { + function firstEntry( + name: PropertyKey, + fields: readonly PropertyKey[] | undefined, + matches: (value: A) => boolean, + ): Entry { const matching = new Set() - const readsByKey = new Map() let slot: Readable | undefined function findFirst() { @@ -472,25 +449,23 @@ export namespace Layout { return { name, - reads: (key) => readsByKey.get(key), + reads: dependencyMask(fields), stage(key, value, mode) { - const matched = tracked(value, matches) + const matched = matches(value) return (valueSlot) => { - readsByKey.set(key, matched.reads) const previous = matching.has(key) - if (matched.result) matching.add(key) - if (!matched.result) matching.delete(key) + if (matched) matching.add(key) + if (!matched) matching.delete(key) if (mode === "add") return - if (slot === valueSlot && !matched.result) { + if (slot === valueSlot && !matched) { findFirst() return } - if (slot !== valueSlot && !previous && matched.result) afterPlacement(valueSlot) + if (slot !== valueSlot && !previous && matched) afterPlacement(valueSlot) } }, remove(key, removedSlot) { matching.delete(key) - readsByKey.delete(key) if (removedSlot && slot === removedSlot) findFirst() }, afterPlacement, @@ -501,13 +476,13 @@ export namespace Layout { const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => { const definition = definitions[name] - if (definition.type === "members") return membersEntry(name, definition.extract) - return firstEntry(name, definition.matches) + if (definition.type === "members") return membersEntry(name, definition.fields, definition.extract) + return firstEntry(name, definition.fields, definition.matches) }) const byName = new Map(entries.map((entry) => [entry.name, entry])) // Stage refresh work for one changed value: explicit member deltas win, - // then projections whose observed reads are disjoint from the change are + // then projections whose declared fields are disjoint from the change are // skipped entirely. Returns undefined when no index work is required. function stageRefresh(key: Key, value: A, mask: number, changes?: unknown) { const record = changes as Readonly> | undefined @@ -519,14 +494,19 @@ export namespace Layout { ;(staged ??= []).push(() => delta(key, change)) continue } - const reads = entry.reads(key) - if (reads !== undefined && (mask & reads) === 0) continue + if ((mask & entry.reads) === 0) continue ;(staged ??= []).push(entry.stage(key, value, "replace")) } return staged } - function publishStaged(value: A, mask: number, staged: readonly Commit[] | undefined, slot: Readable) { + function publishStaged( + previous: A, + value: A, + mask: number, + staged: readonly Commit[] | undefined, + slot: Readable, + ) { // Indexes are plain data, not reactive state; the transaction exists so // subscribers cannot observe published values with stale indexes. With // no staged commits there is nothing to observe out of order. @@ -538,16 +518,12 @@ export namespace Layout { }) function publishValue() { - return withPending(mask, () => values.update(value)) - } - } - - function withPending(mask: number | ReadonlyMap, f: () => T) { - pending = mask - try { - return f() - } finally { - pending = undefined + pending = { previous, next: value, mask } + try { + return values.update(value) + } finally { + pending = undefined + } } } @@ -555,13 +531,13 @@ export namespace Layout { ...values, set(next) { const keys = next.map(plan.keyOf) - if (new Set(keys).size !== keys.length) throw new Error("Keyed values must have unique keys") + const retained = new Set(keys) + if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys") const existing = new Map() values.slots().forEach((slot) => { const value = slot() existing.set(plan.keyOf(value), value) }) - const masks = new Map() // Stage user-code projections for new and changed values only; // retained equivalent values keep their index state and reads. const staged: Array<{ readonly key: Key; readonly commits: readonly Commit[] }> = [] @@ -571,44 +547,52 @@ export namespace Layout { staged.push({ key, commits: entries.map((entry) => entry.stage(key, value, "add")) }) return } - const mask = plan.diff(existing.get(key)!, value) - masks.set(key, mask) + const previous = existing.get(key)! + if (previous === value) return + const mask = plan.diff(previous, value) if (mask === 0) return const commits = stageRefresh(key, value, mask) if (commits) staged.push({ key, commits }) }) - const retained = new Set(keys) + const structure = values.slots() return Transaction.run(() => { - const changed = withPending(masks, () => values.set(next)) - existing.forEach((_value, key) => { - if (!retained.has(key)) entries.forEach((entry) => entry.remove(key)) - }) + const changed = values.set(next) + if (!changed) return false + const structural = structure !== values.slots() + if (structural) + existing.forEach((_value, key) => { + if (!retained.has(key)) entries.forEach((entry) => entry.remove(key)) + }) staged.forEach((item) => { const slot = values.get(item.key)! item.commits.forEach((commit) => commit(slot)) }) - entries.forEach((entry) => entry.afterRebuild()) - return changed + if (structural) entries.forEach((entry) => entry.afterRebuild?.()) + return true }) }, update(value) { const key = plan.keyOf(value) const slot = values.get(key) if (!slot) return false - const mask = plan.diff(slot(), value) + const previous = slot() + if (previous === value) return false + const mask = plan.diff(previous, value) const staged = mask === 0 ? undefined : stageRefresh(key, value, mask) - return publishStaged(value, mask, staged, slot) + return publishStaged(previous, value, mask, staged, slot) }, modify(key, f, changes) { const slot = values.get(key) if (!slot) return false - const value = f(slot()) + const previous = slot() + const value = f(previous) // Publication below targets the slot for the value's own key, so the // modify key-preservation invariant must be enforced here. if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key") - const mask = plan.diff(slot(), value) + if (previous === value) return false + const mask = plan.diff(previous, value) const staged = mask === 0 ? undefined : stageRefresh(key, value, mask, changes?.members) - return publishStaged(value, mask, staged, slot) + return publishStaged(previous, value, mask, staged, slot) }, insert(value, position) { const key = plan.keyOf(value) @@ -618,7 +602,7 @@ export namespace Layout { return Transaction.run(() => { const slot = values.insert(value, position) staged.forEach((commit) => commit(slot)) - entries.forEach((entry) => entry.afterPlacement(slot)) + entries.forEach((entry) => entry.afterPlacement?.(slot)) return slot }) }, @@ -636,7 +620,7 @@ export namespace Layout { if (!slot) return false return Transaction.run(() => { const moved = values.move(key, position) - if (moved) entries.forEach((entry) => entry.afterPlacement(slot)) + if (moved) entries.forEach((entry) => entry.afterPlacement?.(slot)) return moved }) }, @@ -712,7 +696,7 @@ export namespace Layout { const factory = Function( "custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => { let changed = 0\n${statements.join("\n")}\nreturn changed }`, - ) as (custom: ReadonlyArray["equivalent"]>) => (left: A, right: A) => number + ) as (custom: ReadonlyArray>) => (left: A, right: A) => number return factory(emitter.custom) } @@ -792,8 +776,8 @@ export namespace Layout { const fields = model.perVariant.get(name) if (!fields) { const variant = variants[name] - const index = emitter.custom.push((left, right) => variant.equivalent(left, right)) - 1 - return `case ${label}: return custom[${index}](left, right) ? 0 : -1` + const index = emitter.custom.push(variant) - 1 + return `case ${label}: return custom[${index}].equivalent(left, right) ? 0 : -1` } const statements = fields.map((field) => { const fieldProperty = JSON.stringify(String(field.name)) @@ -804,17 +788,17 @@ export namespace Layout { const factory = Function( "custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => { if (left[${property}] !== right[${property}]) return -1; switch (left[${property}]) { ${cases.join("\n")}\ndefault: return -1 } }`, - ) as (custom: ReadonlyArray["equivalent"]>) => (left: unknown, right: unknown) => number + ) as (custom: ReadonlyArray>) => (left: unknown, right: unknown) => number return factory(emitter.custom) } - // Whole-tree generation: one Function() per plan whose source inlines struct - // comparisons, emits real loops for arrays, and hoists unions into named - // inner functions the engine can inline. User-supplied equivalence functions - // remain indirect calls through the `custom` array; everything structural - // compiles to direct code with no interior closure boundaries. + // Whole-tree generation: one Function() per plan operation whose source + // inlines struct comparisons, emits real loops for arrays, and hoists unions + // into named inner functions the engine can inline. User-supplied equivalence + // functions remain indirect calls through the `custom` array; everything + // structural compiles to direct code with no interior closure boundaries. type Emitter = { - readonly custom: Array["equivalent"]> + readonly custom: Field[] readonly declarations: string[] readonly names: Map } @@ -849,7 +833,7 @@ export namespace Layout { function assemble(emitter: Emitter, body: string) { const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as ( - custom: ReadonlyArray["equivalent"]>, + custom: ReadonlyArray>, ) => (left: A, right: A) => boolean return factory(emitter.custom) } @@ -896,8 +880,8 @@ export namespace Layout { }) return `${name}(${left}, ${right})` } - const index = emitter.custom.push((left, right) => field.equivalent(left, right)) - 1 - return `custom[${index}](${left}, ${right})` + const index = emitter.custom.push(field) - 1 + return `custom[${index}].equivalent(${left}, ${right})` } function declareUnion( diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts index 6be648be09df..69c90360dad8 100644 --- a/packages/quark/src/solid.ts +++ b/packages/quark/src/solid.ts @@ -7,9 +7,9 @@ export function useValue(readable: Readable): Accessor { } /** - * Reactive accessor for one keyed slot. Tracks structure only until the slot - * exists; afterwards value changes flow through the slot alone, so unrelated - * structural churn cannot re-render the consumer. + * Reactive accessor for one keyed slot. Structural changes re-resolve the + * slot, while memo equality prevents an unchanged slot from propagating to the + * consumer. Value changes flow through the slot itself. */ export function useSlot(keyed: Keyed.Keyed, key: () => Key): Accessor { const structure = useValue(keyed.slots) diff --git a/packages/tui/bench/session-timeline.ts b/packages/tui/bench/session-timeline.ts index adcdfd0483ee..e3ff4ef7f4fb 100644 --- a/packages/tui/bench/session-timeline.ts +++ b/packages/tui/bench/session-timeline.ts @@ -1,4 +1,4 @@ -import { Keyed } from "effect-quark" +import { Keyed } from "@opencode-ai/quark" import { createStore, produce } from "solid-js/store" import { SessionTimeline, type PartRef } from "../src/routes/session/timeline" import { createHarness, type Workload } from "../../quark/bench/harness" diff --git a/packages/tui/package.json b/packages/tui/package.json index 655f980e1c1d..4cbfbc3a6276 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -87,7 +87,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", - "effect-quark": "workspace:*", + "@opencode-ai/quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts index bd454f84afd0..f4c0a8de208d 100644 --- a/packages/tui/src/routes/session/content.ts +++ b/packages/tui/src/routes/session/content.ts @@ -1,5 +1,5 @@ import type { SessionMessageAssistant } from "@opencode-ai/client" -import { Keyed, Layout } from "effect-quark" +import { Keyed, Layout } from "@opencode-ai/quark" /** * Stable per-part reactive slots for assistant message content. diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1f6b87e2f184..92f9b9925448 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -13,7 +13,7 @@ import { Switch, useContext, } from "solid-js" -import { KeyedFor, useSlot, useValue } from "effect-quark/solid" +import { KeyedFor, useSlot, useValue } from "@opencode-ai/quark/solid" import { SessionContent } from "./content" import path from "node:path" import { EOL, tmpdir } from "node:os" diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 90cf0dfb00a7..931dc7946cef 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,5 +1,5 @@ -import { Keyed } from "effect-quark" -import { useValue } from "effect-quark/solid" +import { Keyed } from "@opencode-ai/quark" +import { useValue } from "@opencode-ai/quark/solid" import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js" import { useData } from "../../context/data" import { useClient } from "../../context/client" diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts index 1b7020362271..924db1e5b303 100644 --- a/packages/tui/src/routes/session/timeline.ts +++ b/packages/tui/src/routes/session/timeline.ts @@ -1,5 +1,5 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { Keyed, Layout, Transaction } from "effect-quark" +import { Keyed, Layout, Transaction } from "@opencode-ai/quark" import { SessionContent } from "./content" const PartRefLayout = Layout.struct({ From d16737bc12dcbc8bb31baccf88ebc0301e391728 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:46:33 -0400 Subject: [PATCH 12/18] chore(quark): sync tests with standalone --- packages/quark/test/keyed.test.ts | 228 +++++++++++++++----- packages/quark/test/layout.test.ts | 320 +++++++++++++++++++++++++++-- packages/quark/test/solid.test.ts | 85 ++++++++ 3 files changed, 564 insertions(+), 69 deletions(-) create mode 100644 packages/quark/test/solid.test.ts diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts index 91bed6d37f80..7fcd839e42cb 100644 --- a/packages/quark/test/keyed.test.ts +++ b/packages/quark/test/keyed.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test" -import { Computed, Keyed } from "../src" +import { Keyed } from "../src/keyed" +import { Computed } from "../src/reactivity" -type Item = { +interface Item { readonly id: number readonly label: string } @@ -9,108 +10,238 @@ type Item = { const item = (id: number, label: string): Item => ({ id, label }) describe("Keyed", () => { - it("keeps slots stable while publishing value and structural changes separately", () => { + it("keeps slot identity across value updates and reorders", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two"), item(3, "three")]) + const [one, two, three] = keyed.slots() + + keyed.set([item(3, "THREE"), item(1, "ONE"), item(2, "TWO")]) + + expect(keyed.slots()).toEqual([three, one, two]) + expect(keyed.slots()[0]).toBe(three) + expect(keyed.slots()[1]).toBe(one) + expect(keyed.slots()[2]).toBe(two) + expect(one()).toEqual(item(1, "ONE")) + expect(two()).toEqual(item(2, "TWO")) + expect(three()).toEqual(item(3, "THREE")) + }) + + it("does not publish structural changes for value-only updates or identical sets", () => { const keyed = Keyed.make({ 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])) + const initial = keyed.slots() + const structures: Array = [] + const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id))) keyed.set([item(1, "ONE"), item(2, "TWO")]) + expect(keyed.slots()).toBe(initial) - 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(1, "ONE"), item(2, "TWO")]) + expect(keyed.slots()).toBe(initial) keyed.set([item(2, "TWO"), item(1, "ONE")]) - expect(keyed.slots()).toEqual([two, one]) expect(structures).toEqual([[2, 1]]) - disposeSlots() - disposeValues() + dispose() + }) + + it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + const one = item(1, "one") + const two = item(2, "two") + keyed.set([one, two]) + const initial = keyed.values() + const snapshots: Array = [] + const dispose = keyed.values.subscribe((values) => snapshots.push(values)) + + keyed.set([one, item(2, "TWO")]) + const updated = keyed.values() + expect(updated).not.toBe(initial) + expect(updated).toEqual([one, item(2, "TWO")]) + + keyed.set([one, updated[1]]) + + expect(keyed.values()).toBe(updated) + expect(snapshots).toEqual([[one, item(2, "TWO")]]) + dispose() }) it("uses custom equivalence to cut off slot and aggregate updates", () => { const keyed = Keyed.make({ key: (value) => value.id, - equivalent: (left, right) => left.label.toLowerCase() === right.label.toLowerCase(), + equivalent: (left, right) => left.id === right.id && left.label.toLowerCase() === right.label.toLowerCase(), }) const original = item(1, "one") keyed.set([original]) const slot = keyed.slots()[0] const aggregate = keyed.values() + const slotValues: Item[] = [] + const aggregateValues: Array = [] + const disposeSlot = slot.subscribe((value) => slotValues.push(value)) + const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values)) keyed.set([item(1, "ONE")]) expect(slot()).toBe(original) expect(keyed.values()).toBe(aggregate) + expect(slotValues).toEqual([]) + expect(aggregateValues).toEqual([]) + + keyed.set([item(1, "changed")]) + + expect(slot()).toEqual(item(1, "changed")) + expect(slotValues).toEqual([item(1, "changed")]) + expect(aggregateValues).toEqual([[item(1, "changed")]]) + disposeSlot() + disposeAggregate() + }) + + it("uses SameValueZero semantics for primitive slot values", () => { + const keyed = Keyed.make({ key: () => "value" }) + keyed.set([-0]) + + expect(keyed.update(0)).toBe(false) + expect(Object.is(keyed.get("value")?.(), -0)).toBe(true) }) - it("creates a fresh slot after removal and reinsertion", () => { + it("creates slots for inserts and fresh slots after remove and reinsert", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(1, "one"), item(2, "two")]) - const [removed, retained] = keyed.slots() + keyed.set([item(1, "one")]) + const removed = keyed.slots()[0] + const removedValues: Item[] = [] + const dispose = removed.subscribe((value) => removedValues.push(value)) 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")) + const inserted = keyed.slots()[0] + keyed.set([item(1, "new one"), item(2, "new two")]) + const [reinserted, retained] = keyed.slots() + + expect(reinserted).not.toBe(removed) + expect(reinserted()).toEqual(item(1, "new one")) + expect(retained).toBe(inserted) + expect(retained()).toEqual(item(2, "new two")) + expect(removed()).toEqual(item(1, "one")) + expect(removedValues).toEqual([]) + dispose() }) - it("rejects duplicate keys without partially updating", () => { + it("rejects duplicate keys before making any partial update", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one"), item(2, "two")]) const slots = keyed.slots() const values = keyed.values() + const notifications: string[] = [] + const disposeOne = slots[0].subscribe(() => notifications.push("one")) + const disposeTwo = slots[1].subscribe(() => notifications.push("two")) + const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots")) + const disposeValues = keyed.values.subscribe(() => notifications.push("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) + expect(keyed.values()).toEqual([item(1, "one"), item(2, "two")]) + expect(notifications).toEqual([]) + disposeOne() + disposeTwo() + disposeSlots() + disposeValues() }) - it("uses SameValueZero key equality", () => { + it("uses SameValueZero equality for zero keys", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(-0, "zero"), item(Number.NaN, "nan")]) - const [zero, nan] = keyed.slots() + keyed.set([item(-0, "negative zero")]) + const zero = keyed.slots()[0] - keyed.set([item(0, "ZERO"), item(Number.NaN, "NAN")]) + keyed.set([item(0, "zero")]) - 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( + expect(keyed.slots()[0]).toBe(zero) + expect(zero()).toEqual(item(0, "zero")) + expect(() => keyed.set([item(0, "zero"), item(-0, "negative zero")])).toThrow("Keyed values must have unique keys") + expect(keyed.slots()).toEqual([zero]) + expect(keyed.values()).toEqual([item(0, "zero")]) + }) + + it("uses SameValueZero equality for NaN keys", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(Number.NaN, "first")]) + const nan = keyed.slots()[0] + + keyed.set([item(Number.NaN, "updated")]) + + expect(keyed.slots()[0]).toBe(nan) + expect(nan()).toEqual(item(Number.NaN, "updated")) + expect(() => keyed.set([item(Number.NaN, "one"), item(Number.NaN, "two")])).toThrow( "Keyed values must have unique keys", ) + expect(keyed.slots()).toEqual([nan]) + expect(keyed.values()).toEqual([item(Number.NaN, "updated")]) }) - it("publishes one settled aggregate when values and structure change together", () => { + it("publishes one glitch-free aggregate when slots and structure change together", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(1, "one"), item(2, "two")]) + keyed.set([item(1, "one"), item(2, "two"), item(3, "three")]) const observations: string[] = [] const summary = Computed.make(() => { - const slots = keyed + const slotKeys = keyed .slots() - .map((slot) => `${slot().id}:${slot().label}`) + .map((slot) => slot().id) .join(",") const values = keyed .values() .map((value) => `${value.id}:${value.label}`) .join(",") - return `${slots}|${values}` + return `${slotKeys}|${values}` }) const dispose = summary.subscribe((value) => observations.push(value)) - keyed.set([item(2, "TWO"), item(3, "three")]) + keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")]) - expect(observations).toEqual(["2:TWO,3:three|2:TWO,3:three"]) + expect(observations).toEqual(["3,2,4|3:THREE,2:TWO,4:four"]) dispose() }) + it("stops slot, structural, and aggregate notifications after disposal", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one")]) + const notifications: string[] = [] + const disposeSlot = keyed.slots()[0].subscribe(() => notifications.push("slot")) + const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots")) + const disposeValues = keyed.values.subscribe(() => notifications.push("values")) + + disposeSlot() + disposeSlots() + disposeValues() + keyed.set([item(2, "two")]) + keyed.set([item(2, "TWO")]) + + expect(notifications).toEqual([]) + }) + + it("supports empty sets and repeated clears without redundant publication", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + expect(keyed.slots()).toEqual([]) + expect(keyed.values()).toEqual([]) + const structures: Array = [] + const aggregates: Array = [] + const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots)) + const disposeValues = keyed.values.subscribe((values) => aggregates.push(values)) + + keyed.set([]) + keyed.set([item(1, "one")]) + keyed.set([]) + const clearedSlots = keyed.slots() + const clearedValues = keyed.values() + keyed.set([]) + + expect(keyed.slots()).toBe(clearedSlots) + expect(keyed.values()).toBe(clearedValues) + expect(structures.map((slots) => slots.length)).toEqual([1, 0]) + expect(aggregates).toEqual([[item(1, "one")], []]) + disposeSlots() + disposeValues() + }) + it("updates one existing slot without publishing structure", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one"), item(2, "two")]) @@ -182,6 +313,9 @@ describe("Keyed", () => { expect(keyed.remove(2)).toBe(true) expect(keyed.remove(2)).toBe(false) expect(keyed.slots()).toEqual([one, four, three]) + expect(() => keyed.insert(item(1, "duplicate"))).toThrow("Keyed value already exists: 1") + expect(() => keyed.insert(item(2, "two"), { before: 5 })).toThrow("Keyed value does not exist: 5") + expect(keyed.move(5)).toBe(false) }) it("counts publications and equivalence suppressions when instrumented", () => { @@ -202,18 +336,4 @@ describe("Keyed", () => { equivalenceSuppressions: 1, }) }) - - it("publishes nothing for a full unchanged rebuild", () => { - const metrics = Keyed.metrics() - const keyed = Keyed.make({ 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) - }) }) diff --git a/packages/quark/test/layout.test.ts b/packages/quark/test/layout.test.ts index 53a70b33c3ca..91e5f66ad622 100644 --- a/packages/quark/test/layout.test.ts +++ b/packages/quark/test/layout.test.ts @@ -13,8 +13,8 @@ const Job = Layout.struct({ }) const Jobs = Layout.collection(Job, ({ members, first }) => ({ - labels: members((job) => job.labels), - nextRetry: first((job) => job.status === "retrying"), + labels: members(["labels"], (job) => job.labels), + nextRetry: first(["status"], (job) => job.status === "retrying"), })) describe("Layout", () => { @@ -37,12 +37,17 @@ describe("Layout", () => { "Keyed layout must declare exactly one key field", ) expect(() => - Layout.compile(Layout.struct({ left: Layout.key(Layout.number), right: Layout.key(Layout.number) })), + Layout.compile( + Layout.struct({ + left: Layout.key(Layout.number), + right: Layout.key(Layout.number), + }), + ), ).toThrow("Keyed layout must declare exactly one key field") }) it("generates the same trusted equivalence as the closure backend", () => { - const closure = Layout.compile(Item) + const closure = Layout.compile(Item, { backend: "closure" }) const generated = Layout.compile(Item, { backend: "generated" }) const values = [ { id: 1, label: "one" }, @@ -53,12 +58,70 @@ describe("Layout", () => { values.forEach((left) => { values.forEach((right) => { expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right)) + expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right)) + expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right)) }) }) }) + it("honors custom primitive comparators in generated plans", () => { + const CaseInsensitive = { + ...Layout.string, + foldCase: true, + equivalent(left: string, right: string) { + return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right + }, + } + const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive }) + const closure = Layout.compile(Value, { backend: "closure" }) + const generated = Layout.compile(Value, { backend: "generated" }) + const left = { id: 1, value: "same" } + const right = { id: 1, value: "SAME" } + + expect(closure.equivalent(left, right)).toBe(true) + expect(generated.equivalent(left, right)).toBe(true) + expect(generated.diff(left, right)).toBe(0) + }) + + it("keeps generated nested keyed unions independent when they share variants", () => { + const variants = { + yes: Layout.struct({ value: Layout.string }), + no: Layout.struct({ value: Layout.string }), + } + const Left = Layout.keyedUnion({ + key: Layout.key("leftID", Layout.number), + tag: "leftType", + variants, + }) + const Right = Layout.keyedUnion({ + key: Layout.key("rightID", Layout.number), + tag: "rightType", + variants, + }) + const Root = Layout.struct({ + id: Layout.key(Layout.number), + left: Left, + right: Right, + }) + const value: Layout.Type = { + id: 1, + left: { leftID: 1, leftType: "yes", value: "same" }, + right: { rightID: 1, rightType: "yes", value: "same" }, + } + const copy = structuredClone(value) + const closure = Layout.compile(Root, { backend: "closure" }) + const generated = Layout.compile(Root, { backend: "generated" }) + + expect(closure.equivalent(value, copy)).toBe(true) + expect(generated.equivalent(value, copy)).toBe(true) + expect(generated.diff(value, copy)).toBe(0) + }) + it("compiles nested discriminated unions and skips immutable fields", () => { - const Ref = Layout.struct({ messageID: Layout.string, partID: Layout.string }) + const Ref = Layout.struct({ + messageID: Layout.string, + partID: Layout.string, + }) const Row = Layout.keyedUnion({ key: Layout.key("id", Layout.string), tag: "type", @@ -82,7 +145,7 @@ describe("Layout", () => { }), }, }) - const plan = Layout.compile(Row) + const plan = Layout.compile(Row, { backend: "closure" }) const generated = Layout.compile(Row, { backend: "generated" }) const group = { id: "group-1", @@ -94,9 +157,19 @@ describe("Layout", () => { completed: false, } - expect(plan.equivalent(group, { ...group, origin: { messageID: "ignored", partID: "ignored" } })).toBe(true) + expect( + plan.equivalent(group, { + ...group, + origin: { messageID: "ignored", partID: "ignored" }, + }), + ).toBe(true) expect(plan.equivalent(group, { ...group, completed: true })).toBe(false) - expect(plan.equivalent(group, { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] })).toBe(false) + expect( + plan.equivalent(group, { + ...group, + pending: [{ messageID: "assistant-1", partID: "read-1" }], + }), + ).toBe(false) expect( plan.equivalent(group, { id: "group-1", @@ -127,8 +200,14 @@ describe("Layout", () => { }) it("includes nested keys in structural equivalence", () => { - const Child = Layout.struct({ id: Layout.key(Layout.number), value: Layout.string }) - const Parent = Layout.struct({ id: Layout.key(Layout.number), child: Child }) + const Child = Layout.struct({ + id: Layout.key(Layout.number), + value: Layout.string, + }) + const Parent = Layout.struct({ + id: Layout.key(Layout.number), + child: Child, + }) const parent = Layout.compile(Parent) expect( @@ -184,6 +263,158 @@ describe("Layout", () => { expect(jobs.first("nextRetry")?.().id).toBe("four") }) + it("skips index projection when the change is disjoint from its declared fields", () => { + let extractions = 0 + let matchChecks = 0 + const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({ + labels: members(["labels"], (job) => { + extractions++ + return job.labels + }), + nextRetry: first(["status"], (job) => { + matchChecks++ + return job.status === "retrying" + }), + })) + const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }]) + const labels = jobs.get("one")!().labels + const baseline = { extractions, matchChecks } + + // Status-only change: labels extraction reads only `labels`, so it skips. + jobs.update({ id: "one", labels, status: "retrying" }) + expect(extractions).toBe(baseline.extractions) + expect(matchChecks).toBe(baseline.matchChecks + 1) + expect(jobs.first("nextRetry")?.().id).toBe("one") + + // Labels-only change: the first-match check reads only `status`, so it skips. + jobs.update({ id: "one", labels: ["urgent"], status: "retrying" }) + expect(extractions).toBe(baseline.extractions + 1) + expect(matchChecks).toBe(baseline.matchChecks + 1) + expect(jobs.hasMember("labels", "urgent")).toBe(true) + expect(jobs.hasMember("labels", "billing")).toBe(false) + + // Equivalent update: nothing runs. + jobs.update({ id: "one", labels: ["urgent"], status: "retrying" }) + expect(extractions).toBe(baseline.extractions + 1) + expect(matchChecks).toBe(baseline.matchChecks + 1) + }) + + it("normalizes numeric dependency names", () => { + let extractions = 0 + const Numeric = Layout.collection( + Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }), + ({ members }) => ({ + zero: members([0], (value) => { + extractions++ + return [value[0]] + }), + }), + ) + const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }]) + const baseline = extractions + + values.update({ id: "one", 0: "zero", other: "after" }) + + expect(extractions).toBe(baseline) + expect(values.hasMember("zero", "zero")).toBe(true) + }) + + it("reuses precomputed field diffs during direct collection mutations", () => { + let comparisons = 0 + const counted: Layout.Field = { + equivalent(left, right) { + comparisons++ + return left === right + }, + } + const CountedJob = Layout.struct({ + id: Layout.key(Layout.string), + value: counted, + status: Layout.string, + }) + const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({ + changed: first(["value"], (job) => job.value === "after"), + })) + const one = { id: "one", value: "before", status: "idle" } + const two = { id: "two", value: "before", status: "idle" } + const jobs = CountedJobs.make([one, two]) + comparisons = 0 + + jobs.set([ + { ...one }, + { ...two, value: "after" }, + ]) + + expect(comparisons).toBe(4) + expect(jobs.get("one")?.()).toBe(one) + expect(jobs.first("changed")?.().id).toBe("two") + + comparisons = 0 + jobs.update({ ...jobs.get("one")!(), status: "busy" }) + expect(comparisons).toBe(1) + + comparisons = 0 + jobs.modify("two", (job) => ({ ...job, value: "done" })) + expect(comparisons).toBe(1) + expect(jobs.first("changed")).toBeUndefined() + }) + + it("refreshes indexes that read a changed union discriminant", () => { + const Row = Layout.keyedUnion({ + key: Layout.key("id", Layout.number), + tag: "type", + variants: { + waiting: Layout.struct({ value: Layout.string }), + ready: Layout.struct({ value: Layout.string }), + }, + }) + const Rows = Layout.collection(Row, ({ members, first }) => ({ + types: members(["type"], (row) => [row.type]), + firstReady: first(["type"], (row) => row.type === "ready"), + })) + const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }]) + + rows.update({ id: 1, type: "ready", value: "same" }) + + expect(rows.get(1)?.().type).toBe("ready") + expect(rows.hasMember("types", "waiting")).toBe(false) + expect(rows.hasMember("types", "ready")).toBe(true) + expect(rows.first("firstReady")?.().type).toBe("ready") + }) + + it("tracks lazy member iterables while they are consumed", () => { + const LazyJobs = Layout.collection(Job, ({ members }) => ({ + statuses: members(function* (job) { + yield job.status + }), + })) + const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }]) + + expect(jobs.hasMember("statuses", "running")).toBe(true) + jobs.update({ id: "one", labels: [], status: "retrying" }) + expect(jobs.hasMember("statuses", "running")).toBe(false) + expect(jobs.hasMember("statuses", "retrying")).toBe(true) + }) + + it("passes actual frozen values to identity and reflection projections", () => { + const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" }) + const accepted = new WeakSet([actual]) + const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({ + properties: members((job) => Object.keys(job)), + selves: members((job) => [job]), + frozen: members((job) => [Object.isFrozen(job)]), + accepted: first((job) => accepted.has(job)), + })) + const jobs = EnumeratedJobs.make([actual]) + + expect(jobs.hasMember("properties", "id")).toBe(true) + expect(jobs.hasMember("properties", "labels")).toBe(true) + expect(jobs.hasMember("properties", "status")).toBe(true) + expect(jobs.hasMember("selves", actual)).toBe(true) + expect(jobs.hasMember("frozen", true)).toBe(true) + expect(jobs.first("accepted")?.()).toBe(actual) + }) + it("applies explicit member deltas without re-extracting unchanged membership", () => { let extractions = 0 const IndexedJobs = Layout.collection(Job, ({ members }) => ({ @@ -206,12 +437,20 @@ describe("Layout", () => { expect(jobs.hasMember("labels", "billing")).toBe(true) expect(jobs.hasMember("labels", "urgent")).toBe(true) - jobs.modify("two", (job) => ({ ...job, labels: [] }), { members: { labels: { remove: ["billing"] } } }) + jobs.modify("two", (job) => ({ ...job, labels: [] }), { + members: { labels: { remove: ["billing"] } }, + }) expect(jobs.hasMember("labels", "billing")).toBe(false) if (false) { - // @ts-expect-error Member deltas require arrays so string members are not split into characters. - jobs.modify("one", (job) => job, { members: { labels: { add: "urgent" } } }) + jobs.modify("one", (job) => job, { + members: { + labels: { + // @ts-expect-error Member deltas require arrays so strings are not split into characters. + add: "urgent", + }, + }, + }) } }) @@ -238,16 +477,67 @@ describe("Layout", () => { ) }) + it("restores pending comparison state when publication throws", () => { + const LabeledJob = Layout.struct({ + id: Layout.key(Layout.string), + label: Layout.string, + status: Layout.string, + }) + const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({ + retry: first(["status"], (job) => job.status === "retrying"), + })) + const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }]) + const slot = jobs.get("one")! + const dispose = slot.subscribe(() => { + throw new Error("listener failed") + }) + + expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed") + dispose() + const equivalent = { ...slot() } + + expect(jobs.set([equivalent])).toBe(false) + expect(slot()).not.toBe(equivalent) + }) + + it("does not leak a pending comparison into reentrant publication", () => { + const LabeledJob = Layout.struct({ + id: Layout.key(Layout.string), + label: Layout.string, + status: Layout.string, + }) + const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({ + retry: first(["status"], (job) => job.status === "retrying"), + })) + const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }]) + const slot = jobs.get("one")! + let reentered = false + let changed: boolean | undefined + const dispose = slot.subscribe(() => { + if (reentered) return + reentered = true + changed = jobs.set([{ ...slot() }]) + }) + + jobs.update({ id: "one", label: "after", status: "running" }) + + expect(changed).toBe(false) + dispose() + }) + it("tracks first matches whose key is undefined", () => { const undefinedField: Layout.Field = { equivalent: Object.is } const OptionalJobs = Layout.collection( Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }), - ({ first }) => ({ retry: first((job) => job.status === "retrying") }), + ({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }), ) const jobs = OptionalJobs.make([{ id: undefined, status: "running" }]) jobs.update({ id: undefined, status: "retrying" }) - expect(jobs.first("retry")?.()).toEqual({ id: undefined, status: "retrying" }) + expect(jobs.first("retry")?.()).toEqual({ + id: undefined, + status: "retrying", + }) }) }) diff --git a/packages/quark/test/solid.test.ts b/packages/quark/test/solid.test.ts new file mode 100644 index 000000000000..37ee26344457 --- /dev/null +++ b/packages/quark/test/solid.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test" +import { createComputed, createRoot, createSignal } from "solid-js" +import { Computed, Keyed, State, Transaction, type Readable } from "../src" +import { useSlot, useValue } from "../src/solid" + +describe("Solid adapter", () => { + it("bridges Quark values into Solid ownership", () => { + const left = State.make(1) + const right = State.make(2) + const total = Computed.make(() => left() + right()) + const values: number[] = [] + + createRoot((dispose) => { + const value = useValue(total) + createComputed(() => values.push(value())) + + Transaction.run(() => { + left.set(2) + right.set(3) + }) + dispose() + }) + + left.set(4) + expect(values).toEqual([3, 5]) + }) + + it("preserves function values", () => { + const first = () => 1 + const second = () => 2 + const state = State.make(first) + let value = first + + createRoot((dispose) => { + const current = useValue(state) + createComputed(() => value = current()) + state.set(second) + dispose() + }) + + expect(value).toBe(second) + }) + + it("switches keyed slots and releases obsolete subscriptions", () => { + const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id }) + keyed.set([ + { id: 1, value: "one" }, + { id: 2, value: "two" }, + ]) + const active = [0, 0] + track(keyed.get(1)!, 0) + track(keyed.get(2)!, 1) + const values: Array = [] + + createRoot((dispose) => { + const [key, setKey] = createSignal(1) + const value = useSlot(keyed, key) + createComputed(() => values.push(value()?.value)) + + keyed.modify(1, (item) => ({ ...item, value: "ONE" })) + keyed.insert({ id: 3, value: "three" }) + setKey(2) + keyed.modify(1, (item) => ({ ...item, value: "ignored" })) + keyed.modify(2, (item) => ({ ...item, value: "TWO" })) + + expect(active).toEqual([0, 1]) + dispose() + }) + + expect(active).toEqual([0, 0]) + expect(values).toEqual(["one", "ONE", "two", "TWO"]) + + function track(slot: Readable<{ readonly id: number; readonly value: string }>, index: number) { + const subscribe = slot.subscribe + slot.subscribe = (listener) => { + active[index]++ + const dispose = subscribe(listener) + return () => { + active[index]-- + dispose() + } + } + } + }) +}) From d063f6b91ffef58019303d951b117c77a9f2f280 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:54:34 -0400 Subject: [PATCH 13/18] perf(quark): sync shared wrapper methods from standalone Vendored sync of quark 15159e5: State and Computed wrappers share this-based set/update/subscribe methods through a node handle, cutting three closures per slot. Verified no opencode consumer detaches quark wrapper methods. --- packages/quark/src/reactivity.ts | 49 ++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts index 7bea77e94bd3..2ffdd3dc748e 100644 --- a/packages/quark/src/reactivity.ts +++ b/packages/quark/src/reactivity.ts @@ -19,19 +19,14 @@ export namespace State { subs: undefined, subsTail: undefined, } - const read = (() => readState(node)) as Writable - read.set = (value) => writeState(node, value) - read.update = (f) => { - // Read untracked: calling update inside a tracked evaluation must not - // make the caller depend on (and re-trigger from) this state. - const previous = swapActiveSub(undefined) - try { - writeState(node, f(readState(node))) - } finally { - activeSub = previous - } - } - read.subscribe = (listener) => subscribeNode(node, read, listener) + // Methods are shared this-based functions rather than per-instance + // closures: one callable and one node per state, and every call site + // stays monomorphic on the shared method identity. + const read = (() => readState(node)) as Writable & Handle> + read.node = node + read.set = stateSet + read.update = stateUpdate + read.subscribe = sharedSubscribe return read } } @@ -47,12 +42,36 @@ export namespace Computed { subs: undefined, subsTail: undefined, } - const read = (() => readComputed(node)) as Readable - read.subscribe = (listener) => subscribeNode(node, read, listener) + const read = (() => readComputed(node)) as Readable & Handle> + read.node = node + read.subscribe = sharedSubscribe return read } } +interface Handle { + node: Node +} + +function stateSet(this: Handle>, value: A): void { + writeState(this.node, value) +} + +function stateUpdate(this: Handle>, f: (value: A) => A): void { + // Read untracked: calling update inside a tracked evaluation must not + // make the caller depend on (and re-trigger from) this state. + const previous = swapActiveSub(undefined) + try { + writeState(this.node, f(readState(this.node))) + } finally { + activeSub = previous + } +} + +function sharedSubscribe(this: (() => A) & Handle, listener: (value: A) => void): () => void { + return subscribeNode(this.node, this, listener) +} + export namespace Transaction { export function run(f: () => A): A { batchDepth++ From d064501292fa2966e58d7f440377ec4f762ccc74 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:55:09 -0400 Subject: [PATCH 14/18] test(quark): bind detached subscribe in solid adapter instrumentation Wrapper methods are shared this-based functions after the standalone sync, so the test's subscribe interception must bind before detaching. --- packages/quark/test/solid.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quark/test/solid.test.ts b/packages/quark/test/solid.test.ts index 37ee26344457..43ebaab0d025 100644 --- a/packages/quark/test/solid.test.ts +++ b/packages/quark/test/solid.test.ts @@ -71,7 +71,7 @@ describe("Solid adapter", () => { expect(values).toEqual(["one", "ONE", "two", "TWO"]) function track(slot: Readable<{ readonly id: number; readonly value: string }>, index: number) { - const subscribe = slot.subscribe + const subscribe = slot.subscribe.bind(slot) slot.subscribe = (listener) => { active[index]++ const dispose = subscribe(listener) From 6ad68000ee53c6dfdfa1cf55e086a388262319c1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 00:09:44 -0400 Subject: [PATCH 15/18] refactor(tui): simplify quark migration after review - fix permission input losing reactivity (raw slot read; regression test) - BackgroundToolHint tracks structure + tool slots instead of whole-collection values - group views share mapArray-backed usePartSlots (per-ref node reuse) - dedupe streaming handlers behind variant-scoped modify/insert helpers - part ID scheme has one definition; hasText required in message navigation - expose read-only PartsView to view components; misc cleanups --- packages/quark/src/solid.ts | 2 +- packages/tui/src/context/data.tsx | 253 ++++++++++-------- packages/tui/src/routes/session/content.ts | 22 +- .../tui/src/routes/session/dialog-message.tsx | 8 +- packages/tui/src/routes/session/index.tsx | 90 +++---- .../src/routes/session/message-navigation.ts | 8 +- .../tui/src/routes/session/permission.tsx | 22 +- packages/tui/src/routes/session/rows.ts | 38 ++- packages/tui/src/routes/session/timeline.ts | 2 + .../test/cli/tui/message-navigation.test.ts | 15 ++ packages/tui/test/cli/tui/permission.test.tsx | 115 ++++++++ 11 files changed, 379 insertions(+), 196 deletions(-) create mode 100644 packages/tui/test/cli/tui/permission.test.tsx diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts index 69c90360dad8..9acd6bcfffc3 100644 --- a/packages/quark/src/solid.ts +++ b/packages/quark/src/solid.ts @@ -11,7 +11,7 @@ export function useValue(readable: Readable): Accessor { * slot, while memo equality prevents an unchanged slot from propagating to the * consumer. Value changes flow through the slot itself. */ -export function useSlot(keyed: Keyed.Keyed, key: () => Key): Accessor { +export function useSlot(keyed: Pick, "slots" | "get">, key: () => Key): Accessor { const structure = useValue(keyed.slots) const slot = createMemo(() => { structure() diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 504f99ac930a..d0f47e0644bc 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -34,7 +34,7 @@ import { SessionContent } from "../routes/session/content" export type DataSessionStatus = "idle" | "running" -const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") +export const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") // Global MCP elicitations temporarily use "global" instead of a real session ID, so the // server cannot recover their Location when settling them. Preserve the event Location @@ -150,6 +150,44 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ // Assistant content lives in per-message keyed part slots, not the Solid // store: streaming deltas publish one slot instead of reconciling arrays. const content = SessionContent.make() + // Variant-scoped slot operations: each owns its part address scheme and + // type guard so the streaming handlers below read as pure transforms. + // A missing collection or key is a normal straggler race and no-ops. + const modifyText = ( + data: { sessionID: string; assistantMessageID: string; ordinal: number }, + f: (part: Extract) => SessionContent.Part, + ) => + content + .get(data.sessionID, data.assistantMessageID) + ?.modify(SessionContent.textID(data.ordinal), (part) => (part.type === "text" ? f(part) : part)) + const modifyReasoning = ( + data: { sessionID: string; assistantMessageID: string; ordinal: number }, + f: (part: Extract) => SessionContent.Part, + ) => + content + .get(data.sessionID, data.assistantMessageID) + ?.modify(SessionContent.reasoningID(data.ordinal), (part) => (part.type === "reasoning" ? f(part) : part)) + const modifyTool = ( + data: { sessionID: string; assistantMessageID: string; callID: string }, + f: (part: Extract) => SessionContent.Part, + ) => + content + .get(data.sessionID, data.assistantMessageID) + ?.modify(data.callID, (part) => (part.type === "tool" ? f(part) : part)) + const insertPart = (data: { sessionID: string; assistantMessageID: string }, part: SessionContent.Part) => { + const parts = content.ensure(data.sessionID, data.assistantMessageID) + if (!parts.has(part.partID)) parts.insert(part) + } + // Shared settlement trailer for tool success and failure. + const settled = ( + part: Extract, + data: { executed: boolean; resultState?: Extract["providerResultState"] }, + completed: number, + ) => ({ + executed: data.executed || part.executed === true, + providerResultState: data.resultState, + time: { ...part.time, completed }, + }) const sync = createSync() const pendingOperations = new Map() @@ -251,6 +289,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ...item.data, time: { created: item.timeCreated }, } + // Placeholder row until the server projects the real compaction; + // pending compactions carry no reason, so "manual" is a stand-in. return { id: item.id, type: "compaction", @@ -566,142 +606,110 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } }) break - case "session.text.started": { - const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) - const partID = SessionContent.textID(event.data.ordinal) - if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID }) + case "session.text.started": + insertPart(event.data, { type: "text", text: "", partID: SessionContent.textID(event.data.ordinal) }) break - } case "session.text.delta": - content - .get(event.data.sessionID, event.data.assistantMessageID) - ?.modify(SessionContent.textID(event.data.ordinal), (part) => - part.type === "text" ? { ...part, text: part.text + event.data.delta } : part, - ) + modifyText(event.data, (part) => ({ ...part, text: part.text + event.data.delta })) break case "session.text.ended": - content - .get(event.data.sessionID, event.data.assistantMessageID) - ?.modify(SessionContent.textID(event.data.ordinal), (part) => - part.type === "text" ? { ...part, text: event.data.text } : part, - ) + modifyText(event.data, (part) => ({ ...part, text: event.data.text })) break - case "session.tool.input.started": { - const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) - if (!parts.has(event.data.callID)) - parts.insert({ - type: "tool", - id: event.data.callID, - name: event.data.name, - time: { created: event.created }, - state: { status: "streaming", input: "" }, - partID: event.data.callID, - }) + case "session.tool.input.started": + insertPart(event.data, { + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { created: event.created }, + state: { status: "streaming", input: "" }, + partID: event.data.callID, + }) break - } case "session.tool.input.delta": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool" || part.state.status !== "streaming") return part - return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } } - }) + modifyTool(event.data, (part) => + part.state.status !== "streaming" + ? part + : { ...part, state: { ...part.state, input: part.state.input + event.data.delta } }, + ) break case "session.tool.input.ended": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool" || part.state.status !== "streaming") return part - return { ...part, state: { ...part.state, input: event.data.text } } - }) + modifyTool(event.data, (part) => + part.state.status !== "streaming" ? part : { ...part, state: { ...part.state, input: event.data.text } }, + ) break case "session.tool.called": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool") return part - return { - ...part, - time: { ...part.time, ran: event.created }, - executed: event.data.executed, - providerState: event.data.state, - state: { status: "running", input: event.data.input, structured: {}, content: [] }, - } - }) + modifyTool(event.data, (part) => ({ + ...part, + time: { ...part.time, ran: event.created }, + executed: event.data.executed, + providerState: event.data.state, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + })) break case "session.tool.progress": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool" || part.state.status !== "running") return part - return { - ...part, - state: { ...part.state, structured: event.data.structured, content: [...event.data.content] }, - } - }) + modifyTool(event.data, (part) => + part.state.status !== "running" + ? part + : { + ...part, + state: { ...part.state, structured: event.data.structured, content: [...event.data.content] }, + }, + ) break case "session.tool.success": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool" || part.state.status !== "running") return part - return { - ...part, - state: { - status: "completed", - input: part.state.input, - structured: event.data.structured, - content: [...event.data.content], - result: event.data.result, - }, - executed: event.data.executed || part.executed === true, - providerResultState: event.data.resultState, - time: { ...part.time, completed: event.created }, - } - }) + modifyTool(event.data, (part) => + part.state.status !== "running" + ? part + : { + ...part, + state: { + status: "completed", + input: part.state.input, + structured: event.data.structured, + content: [...event.data.content], + result: event.data.result, + }, + ...settled(part, event.data, event.created), + }, + ) break case "session.tool.failed": - content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { - if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running")) - return part - return { - ...part, - state: { - status: "error", - error: event.data.error, - input: typeof part.state.input === "string" ? {} : part.state.input, - structured: part.state.status === "running" ? part.state.structured : {}, - content: part.state.status === "running" ? part.state.content : [], - result: event.data.result, - }, - executed: event.data.executed || part.executed === true, - providerResultState: event.data.resultState, - time: { ...part.time, completed: event.created }, - } - }) + modifyTool(event.data, (part) => + part.state.status !== "streaming" && part.state.status !== "running" + ? part + : { + ...part, + state: { + status: "error", + error: event.data.error, + input: typeof part.state.input === "string" ? {} : part.state.input, + structured: part.state.status === "running" ? part.state.structured : {}, + content: part.state.status === "running" ? part.state.content : [], + result: event.data.result, + }, + ...settled(part, event.data, event.created), + }, + ) break - case "session.reasoning.started": { - const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) - const partID = SessionContent.reasoningID(event.data.ordinal) - if (!parts.has(partID)) - parts.insert({ - type: "reasoning", - text: "", - state: event.data.state, - time: { created: event.created }, - partID, - }) + case "session.reasoning.started": + insertPart(event.data, { + type: "reasoning", + text: "", + state: event.data.state, + time: { created: event.created }, + partID: SessionContent.reasoningID(event.data.ordinal), + }) break - } case "session.reasoning.delta": - content - .get(event.data.sessionID, event.data.assistantMessageID) - ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => - part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part, - ) + modifyReasoning(event.data, (part) => ({ ...part, text: part.text + event.data.delta })) break case "session.reasoning.ended": - content - .get(event.data.sessionID, event.data.assistantMessageID) - ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => { - if (part.type !== "reasoning") return part - return { - ...part, - text: event.data.text, - time: { created: part.time?.created ?? event.created, completed: event.created }, - state: event.data.state !== undefined ? event.data.state : part.state, - } - }) + modifyReasoning(event.data, (part) => ({ + ...part, + text: event.data.text, + time: { created: part.time?.created ?? event.created, completed: event.created }, + state: event.data.state !== undefined ? event.data.state : part.state, + })) break case "session.retry.scheduled": message.update(event.data.sessionID, (draft, index) => { @@ -981,12 +989,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sync(sessionID: string) { return sync.run(`session.message:${sessionID}`, async () => { await syncPending(sessionID) - const localInputs = pendingInputs(sessionID).map((item) => item.id) - const pendingMessages = [...(store.session.pending[sessionID] ?? [])].map(message.fromPending) + const inputIDs = () => pendingInputs(sessionID).map((item) => item.id) + // Snapshot pending state before the fetch: inputs admitted before + // the fetch must survive even if the server promotes them while + // the list request is in flight. The post-fetch snapshot covers + // inputs admitted during the fetch. + const localInputs = inputIDs() + const pendingMessages = (store.session.pending[sessionID] ?? []).map(message.fromPending) const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" }) const next = projected.data.toReversed() const index = new Map(next.map((message, index) => [message.id, index])) - const localInputIDs = new Set([...localInputs, ...pendingInputs(sessionID).map((item) => item.id)]) + const localInputIDs = new Set([...localInputs, ...inputIDs()]) localInputIDs.forEach((messageID) => { const position = messageIndex.get(sessionID)?.get(messageID) const item = position === undefined ? undefined : store.session.message[sessionID]?.[position] @@ -1004,6 +1017,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }, parts(sessionID: string, messageID: string) { + // Deliberately ensure-on-read: consumers capture the collection + // for their lifetime (see useSlot), so identity per (session, + // message) must be stable even when reads precede streaming. + // Stray collections are reclaimed by prune on the next sync. return content.ensure(sessionID, messageID) }, invalidate(sessionID: string) { diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts index f4c0a8de208d..eaff9c656034 100644 --- a/packages/tui/src/routes/session/content.ts +++ b/packages/tui/src/routes/session/content.ts @@ -13,6 +13,8 @@ export namespace SessionContent { type ContentPart = SessionMessageAssistant["content"][number] export type Part = ContentPart & { readonly partID: string } export type Parts = Keyed.Keyed + /** Read surface for view components; mutation stays with the data layer. */ + export type PartsView = Pick // Streamed sub-objects are replaced immutably on change, so reference // equality is the correct (and cheapest) field comparator for them. @@ -52,10 +54,28 @@ export namespace SessionContent { const ordinals = { text: 0, reasoning: 0 } return content.map((part) => { if (part.type === "tool") return { ...part, partID: part.id } - return { ...part, partID: `${part.type}:${ordinals[part.type]++}` } + const id = part.type === "text" ? textID : reasoningID + return { ...part, partID: id(ordinals[part.type]++) } }) } + /** Non-empty text parts of one message, in slot order. */ + export function textParts(parts: PartsView) { + return parts + .values() + .filter((part): part is Extract => part.type === "text" && part.text.trim().length > 0) + } + + export function hasText(parts: PartsView) { + return textParts(parts).length > 0 + } + + export function text(parts: PartsView) { + return textParts(parts) + .map((part) => part.text) + .join("\n") + } + export function make(options?: { readonly metrics?: Keyed.Metrics }) { const collections = new Map() const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}` diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index e2216191770e..edadfcdd7758 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -6,6 +6,7 @@ import { useToast } from "../../ui/toast" import { useClient } from "../../context/client" import { errorMessage } from "../../util/error" import { DialogFork } from "./dialog-fork" +import { SessionContent } from "./content" import type { PromptInfo } from "../../prompt/history" export function DialogMessage(props: { @@ -62,12 +63,7 @@ export function DialogMessage(props: { value.type === "user" ? value.text : value.type === "assistant" - ? data.session.message - .parts(props.sessionID, props.messageID) - .values() - .filter((content) => content.type === "text") - .map((content) => content.text) - .join("\n") + ? SessionContent.text(data.session.message.parts(props.sessionID, props.messageID)) : "text" in value ? value.text : "" diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 92f9b9925448..0929c4147a88 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -5,6 +5,7 @@ import { createMemo, createSignal, For, + mapArray, Match, on, onCleanup, @@ -189,6 +190,10 @@ export function Session() { const client = useClient() const editor = useEditorContext() const rows = createSessionRows(() => route.sessionID) + const partsOf = (messageID: string) => data.session.message.parts(route.sessionID, messageID) + // Slot reads here are deliberately untracked: messageBoundaryIDs depends + // only on structurally-immutable row fields (id, type, origin), so this memo + // re-runs on structural changes (rows.slots) and message list changes only. const boundaries = createMemo(() => messageBoundaryIDs( rows.slots().map((slot) => slot()), @@ -294,11 +299,7 @@ export function Session() { direction, children: scroll.getChildren(), messages: messages(), - hasText: (messageID) => - data.session.message - .parts(route.sessionID, messageID) - .values() - .some((part) => part.type === "text" && part.text.trim()), + hasText: (messageID) => SessionContent.hasText(partsOf(messageID)), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, currentID: navigationMessage(), @@ -668,8 +669,7 @@ export function Session() { return } - const textParts = data.session.message - .parts(route.sessionID, lastAssistantMessage.id) + const textParts = partsOf(lastAssistantMessage.id) .values() .filter((part) => part.type === "text") if (textParts.length === 0) { @@ -710,7 +710,7 @@ export function Session() { const sessionData = session() if (!sessionData) return const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) => - data.session.message.parts(route.sessionID, messageID).values(), + partsOf(messageID).values(), ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) @@ -739,7 +739,7 @@ export function Session() { const content = options.format === "markdown" ? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) => - data.session.message.parts(route.sessionID, messageID).values(), + partsOf(messageID).values(), ) : await (async () => { if (options.debug) { @@ -928,15 +928,12 @@ export function Session() { data.session.message.get(route.sessionID, messageID)} - parts={(messageID) => data.session.message.parts(route.sessionID, messageID)} + parts={partsOf} boundaryID={boundaries()[index()]} /> )} - data.session.message.parts(route.sessionID, messageID)} - /> + SessionMessageInfo | undefined - parts: (messageID: string) => SessionContent.Parts + parts: (messageID: string) => SessionContent.PartsView boundaryID?: string }) { return ( @@ -1082,7 +1079,7 @@ function SessionRowView(props: { function BackgroundToolHint(props: { messages: SessionMessageInfo[] - parts: (messageID: string) => SessionContent.Parts + parts: (messageID: string) => SessionContent.PartsView }) { const { themeV2 } = useTheme() const shortcut = Keymap.useShortcut("session.background") @@ -1091,17 +1088,23 @@ function BackgroundToolHint(props: { (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, ), ) - const parts = createMemo(() => { + // Track the part structure (publishes only on part insert/remove) and the + // individual tool slots; text and reasoning deltas never reach this memo. + const toolSlots = createMemo(() => { const message = current() - return message ? useValue(props.parts(message.id).values) : undefined + if (!message) return [] + const parts = props.parts(message.id) + return useValue(parts.slots)() + .filter((slot) => slot().type === "tool") + .map((slot) => useValue(slot)) }) - const visible = createMemo( - () => - parts()?.()?.some((part) => { - if (part.type !== "tool" || part.state.status !== "running") return false - const display = toolDisplay(part.name) - return display === "shell" || display === "subagent" - }) ?? false, + const visible = createMemo(() => + toolSlots().some((tool) => { + const part = tool() + if (part.type !== "tool" || part.state.status !== "running") return false + const display = toolDisplay(part.name) + return display === "shell" || display === "subagent" + }), ) return ( @@ -1142,10 +1145,18 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { ) } +// Per-ref slot accessors for group views. mapArray reuses entries by ref +// identity, so appending one ref to a group creates one new slot subscription +// instead of rebuilding the whole accessor list; value changes flow through +// the individual slots. +function usePartSlots(parts: (messageID: string) => SessionContent.PartsView, refs: () => readonly PartRef[]) { + return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), () => ref.partID) })) +} + function SessionPartView(props: { partRef: PartRef message: (messageID: string) => SessionMessageInfo | undefined - parts: (messageID: string) => SessionContent.Parts + parts: (messageID: string) => SessionContent.PartsView }) { const message = createMemo(() => props.message(props.partRef.messageID)) // One reactive slot per part: unrelated deltas in the same message cannot @@ -1178,21 +1189,14 @@ function SessionReasoningGroupView(props: { refs: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined - parts: (messageID: string) => SessionContent.Parts + parts: (messageID: string) => SessionContent.PartsView }) { const ctx = use() const { themeV2, syntax } = useTheme() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - // Slot accessors are created per ref list; value changes flow through the - // individual slots without re-running the accessor construction. - const accessors = createMemo(() => - props.refs.map((ref) => ({ - ref, - part: useSlot(props.parts(ref.messageID), () => ref.partID), - })), - ) + const accessors = usePartSlots(props.parts, () => props.refs) const parts = createMemo(() => accessors().flatMap((entry) => { const message = props.message(entry.ref.messageID) @@ -1312,22 +1316,18 @@ function SessionGroupView(props: { pending: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined - parts: (messageID: string) => SessionContent.Parts + parts: (messageID: string) => SessionContent.PartsView }) { const { themeV2 } = useTheme() const ctx = use() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - // Slot accessors per ref list: tool state changes flow through individual - // slots; the accessor lists rebuild only when the refs themselves change. - const slots = (refs: () => readonly PartRef[]) => - createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID))) - const groupedSlots = slots(() => props.refs) - const pendingSlots = slots(() => props.pending) - const parts = (accessors: readonly (() => SessionContent.Part | undefined)[]) => - accessors.flatMap((accessor) => { - const part = accessor() + const groupedSlots = usePartSlots(props.parts, () => props.refs) + const pendingSlots = usePartSlots(props.parts, () => props.pending) + const parts = (entries: readonly { readonly part: () => SessionContent.Part | undefined }[]) => + entries.flatMap((entry) => { + const part = entry.part() if (part?.type !== "tool") return [] return [part] }) diff --git a/packages/tui/src/routes/session/message-navigation.ts b/packages/tui/src/routes/session/message-navigation.ts index 29c212544079..e77c549f3fa0 100644 --- a/packages/tui/src/routes/session/message-navigation.ts +++ b/packages/tui/src/routes/session/message-navigation.ts @@ -19,7 +19,9 @@ export function findMessageBoundary(input: { direction: "next" | "prev" children: readonly MessageChild[] messages: readonly SessionMessageInfo[] - hasText?: (messageID: string) => boolean + // Assistant text lives in SessionContent slots; the store `content` field is + // fetch-time only, so callers must supply the live text predicate. + hasText: (messageID: string) => boolean scrollTop: number viewportY: number currentID?: string @@ -36,9 +38,7 @@ export function findMessageBoundary(input: { return [{ id: child.id, y, top: y }] } if (input.userOnly || message.type !== "assistant") return [] - const hasText = - input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim()) - if (!hasText) return [] + if (!input.hasText(message.id)) return [] const y = input.scrollTop + child.y - input.viewportY return [{ id: child.id, y, top: Math.max(0, y - 1) }] }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 97c385227680..9f596a017ed4 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -15,9 +15,23 @@ import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" import { Keymap } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" +import { useSlot } from "@opencode-ai/quark/solid" type PermissionStage = "permission" | "always" | "reject" +/** Resolved tool input for a permission request, once input streaming settles. */ +export function usePermissionInput(request: PermissionV2Request): () => Record { + const data = useData() + const tool = request.source + if (!tool) return () => ({}) + const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), () => tool.callID) + return createMemo(() => { + const item = part() + if (item?.type === "tool" && item.state.status !== "streaming") return item.state.input + return {} + }) +} + function EditBody(props: { request: PermissionV2Request; patch?: string }) { const themeState = useTheme() const themeV2 = themeState.themeV2 @@ -143,13 +157,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const pathFormatter = usePathFormatter() const session = createMemo(() => data.session.get(props.request.sessionID)) - const input = createMemo(() => { - const tool = props.request.source - if (!tool) return {} - const part = data.session.message.parts(props.request.sessionID, tool.messageID).get(tool.callID)?.() - if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input - return {} - }) + const input = usePermissionInput(props.request) const { themeV2 } = useTheme() diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 931dc7946cef..c8c697a1c841 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,8 +1,9 @@ import { Keyed } from "@opencode-ai/quark" import { useValue } from "@opencode-ai/quark/solid" import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js" -import { useData } from "../../context/data" +import { messageIDFromEvent, useData } from "../../context/data" import { useClient } from "../../context/client" +import { SessionContent } from "./content" import { SessionTimeline, compactionQueuedRow, @@ -36,7 +37,6 @@ export function createSessionRows(sessionID: Accessor, options?: { reado state.replace(value, isPending, pendingPermissions()) }) } - const mutate = (f: () => void) => batch(f) function reduce() { const messages = data.session.message.list(sessionID()) @@ -69,7 +69,7 @@ export function createSessionRows(sessionID: Accessor, options?: { reado createEffect(() => { const pending = pendingPermissions() - mutate(() => state.repartition(pending)) + batch(() => state.repartition(pending)) }) createEffect( @@ -132,20 +132,24 @@ export function createSessionRows(sessionID: Accessor, options?: { reado ) const appendMessage = (messageID: string) => - mutate(() => { + batch(() => { const pending = isPending(messageID) const message = data.session.message.get(sessionID(), messageID) state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" }) }) - const appendPart = (ref: PartRef, part: AppendPart) => mutate(() => state.appendPart(ref, part)) + const appendPart = (ref: PartRef, part: AppendPart) => { + // Streaming deltas after the first are no-ops; skip the batch machinery. + if (state.hasPart(ref)) return + batch(() => state.appendPart(ref, part)) + } - const appendFooter = (messageID: string) => mutate(() => state.appendFooter(messageID)) + const appendFooter = (messageID: string) => batch(() => state.appendFooter(messageID)) - const removeFooter = (messageID: string) => mutate(() => state.removeFooter(messageID)) + const removeFooter = (messageID: string) => batch(() => state.removeFooter(messageID)) const message = (event: { id: string; data: { sessionID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) + if (event.data.sessionID === sessionID()) appendMessage(messageIDFromEvent(event.id)) } const input = (event: { data: { @@ -163,35 +167,41 @@ export function createSessionRows(sessionID: Accessor, options?: { reado const subscriptions = [ data.on("session.input.admitted", input), data.on("session.compaction.started", (event) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_")) + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? messageIDFromEvent(event.id)) }), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) - appendMessage(event.id.replace(/^evt_/, "msg_")) + appendMessage(messageIDFromEvent(event.id)) }), data.on("session.shell.started", message), data.on("session.agent.selected", message), data.on("session.model.selected", message), data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID() && event.data.delta.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" }) + appendPart( + { messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) }, + { type: "text" }, + ) }), data.on("session.text.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" }) + appendPart( + { messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) }, + { type: "text" }, + ) }), data.on("session.reasoning.delta", (event) => { if (event.data.sessionID === sessionID() && event.data.delta.trim()) appendPart( - { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, + { messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) }, { type: "reasoning" }, ) }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) appendPart( - { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, + { messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) }, { type: "reasoning" }, ) }), diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts index 924db1e5b303..8a47763a7738 100644 --- a/packages/tui/src/routes/session/timeline.ts +++ b/packages/tui/src/routes/session/timeline.ts @@ -177,6 +177,8 @@ export namespace SessionTimeline { appendFooter, removeFooter, repartition, + /** Cheap no-op check so per-delta callers can skip batching machinery. */ + hasPart: (ref: PartRef) => state.hasMember("parts", partRowID(ref)), } } } diff --git a/packages/tui/test/cli/tui/message-navigation.test.ts b/packages/tui/test/cli/tui/message-navigation.test.ts index 97a29458f3b1..8f4936bc641b 100644 --- a/packages/tui/test/cli/tui/message-navigation.test.ts +++ b/packages/tui/test/cli/tui/message-navigation.test.ts @@ -7,6 +7,10 @@ const messages: SessionMessageInfo[] = [ assistant("assistant-1", "Response"), { type: "user", id: "user-2", text: "Second", time: { created: 2 } }, ] +const hasText = (messageID: string) => { + const message = messages.find((item) => item.id === messageID) + return message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()) +} const children = [ { id: "user-1", y: 0 }, { id: "assistant-1", y: 20 }, @@ -24,6 +28,7 @@ test("finds the next user message without stopping at an assistant message", () direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, userOnly: true, @@ -37,6 +42,7 @@ test("finds the previous user message without stopping at an assistant message", direction: "prev", children: children.map((child) => ({ ...child, y: child.y - 35 })), messages, + hasText, scrollTop: 35, viewportY: 0, userOnly: true, @@ -50,6 +56,7 @@ test("preserves navigation across both user and assistant messages", () => { direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, }), @@ -59,6 +66,7 @@ test("preserves navigation across both user and assistant messages", () => { direction: "prev", children: children.map((child) => ({ ...child, y: child.y - 35 })), messages, + hasText, scrollTop: 35, viewportY: 0, }), @@ -71,6 +79,7 @@ test("uses the selected message when the viewport is too tall to scroll", () => direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-1", @@ -82,6 +91,7 @@ test("uses the selected message when the viewport is too tall to scroll", () => direction: "prev", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-2", @@ -96,6 +106,7 @@ test("stops at the first and last selected user message", () => { direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-2", @@ -107,6 +118,7 @@ test("stops at the first and last selected user message", () => { direction: "prev", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-1", @@ -121,6 +133,7 @@ test("keeps the logical boundary when layout temporarily moves it outside the vi direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-2", @@ -135,6 +148,7 @@ test("stops at the first and last message", () => { direction: "next", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-2", @@ -145,6 +159,7 @@ test("stops at the first and last message", () => { direction: "prev", children, messages, + hasText, scrollTop: 0, viewportY: 0, currentID: "user-1", diff --git a/packages/tui/test/cli/tui/permission.test.tsx b/packages/tui/test/cli/tui/permission.test.tsx new file mode 100644 index 000000000000..fe89af4cb04f --- /dev/null +++ b/packages/tui/test/cli/tui/permission.test.tsx @@ -0,0 +1,115 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client" +import { createEffect, type ParentProps } from "solid-js" +import { ClientProvider, useClient } from "../../../src/context/client" +import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" +import { LocationProvider, useLocation } from "../../../src/context/location" +import { usePermissionInput } from "../../../src/routes/session/permission" +import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function SyncLocation() { + const data = useData() + const location = useLocation() + createEffect(() => location.set(data.location.default())) + return null +} + +function DataProvider(props: ParentProps) { + return ( + + + + {props.children} + + + ) +} + +function emitEvent(events: ReturnType, event: OpenCodeEvent) { + events.emit({ ...event, location: { directory } }) +} + +test("permission input tracks the tool part slot as input settles", async () => { + const events = createEventStream() + const sessionID = "session-permission-input" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let data!: ReturnType + let client!: ReturnType + let input!: () => unknown + + const request = { + id: "perm_1", + sessionID, + permission: "shell", + resources: [], + metadata: {}, + source: { messageID: "message-assistant", callID: "call-permission" }, + time: { created: 1 }, + } as unknown as PermissionV2Request + + function Probe() { + data = useData() + client = useClient() + // Mounted like the permission dialog: before the tool call has settled. + input = usePermissionInput(request) + return + } + + const app = await testRender(() => ( + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + expect(input()).toEqual({}) + + emitEvent(events, { + id: "evt_perm_tool_started", + created: 1, + type: "session.tool.input.started", + durable: { aggregateID: `session_${sessionID}`, seq: 0, version: 1 }, + data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission", name: "shell" }, + } as unknown as OpenCodeEvent) + emitEvent(events, { + id: "evt_perm_tool_called", + created: 2, + type: "session.tool.called", + durable: { aggregateID: `session_${sessionID}`, seq: 1, version: 1 }, + data: { + sessionID, + assistantMessageID: "message-assistant", + callID: "call-permission", + input: { command: "rm -rf ./dist" }, + timestamp: 2, + }, + } as unknown as OpenCodeEvent) + + // The prompt is already mounted; the resolved input must flow through the + // part slot once the tool call settles out of input streaming. + await wait(() => { + const value = input() + return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist" + }) + } finally { + app.renderer.destroy() + } +}) From a2b3be0f16ad16ffb78085dbd4e4e8e64d29caf4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 10:28:36 -0400 Subject: [PATCH 16/18] refactor(tui): adopt reactive first index and read-only keyed surface - quark: Collection.first(name) is now a stable Readable; Keyed.ReadOnly names the read surface; useSlot takes plain constant keys; Layout.collectionOf() replaces the content plan double cast - BackgroundToolHint reads a backgroundRunning first index instead of hand-tracking structure plus tool slots - toolDisplay moves to util/tool-display (content.ts needs it for the index) --- packages/quark/bench/collection.ts | 195 ++++++++++++++++++ packages/quark/src/keyed.ts | 10 +- packages/quark/src/layout.ts | 83 +++++--- packages/quark/src/reactivity.ts | 14 +- packages/quark/src/solid.ts | 9 +- packages/quark/test/layout.test.ts | 50 +++-- packages/quark/test/solid.test.ts | 17 +- packages/tui/src/routes/session/content.ts | 17 +- packages/tui/src/routes/session/index.tsx | 50 +---- .../tui/src/routes/session/permission.tsx | 2 +- packages/tui/src/util/tool-display.ts | 23 +++ .../tui/inline-tool-wrap-snapshot.test.tsx | 2 +- 12 files changed, 374 insertions(+), 98 deletions(-) create mode 100644 packages/quark/bench/collection.ts diff --git a/packages/quark/bench/collection.ts b/packages/quark/bench/collection.ts new file mode 100644 index 000000000000..ce6c14819ab8 --- /dev/null +++ b/packages/quark/bench/collection.ts @@ -0,0 +1,195 @@ +import { Layout } from "../src/layout" +import { createHarness, type Workload } from "./harness" + +type Job = { + readonly id: number + readonly labels: readonly number[] + readonly status: "running" | "retrying" + readonly value: number +} + +const size = 1_000 +const iterations = 200_000 +const initial = Array.from({ length: size }, (_, id): Job => ({ + id, + labels: [id], + status: id === 750 ? "retrying" : "running", + value: 0, +})) +const JobLayout = Layout.struct({ + id: Layout.key(Layout.number), + labels: Layout.array(Layout.number), + status: Layout.string, + value: Layout.number, +}) +const Jobs = Layout.collection(JobLayout, ({ members, first }) => ({ + labels: members(["labels"], (job) => job.labels), + retry: first(["status"], (job) => job.status === "retrying"), +})) +const LabeledJobs = Layout.collection(JobLayout, ({ members }) => ({ + labels: members(["labels"], (job) => job.labels), +})) +const JobPlan = Layout.compile(JobLayout) +const bench = createHarness({ warmup: 10_000, width: 38 }) + +function manualValueUpdate(): Workload { + const jobs = JobPlan.make(initial) + const labels = new Set(initial.flatMap((job) => job.labels)) + const retry = jobs.get(750)! + return { + run(index) { + const id = index % size + jobs.modify(id, (job) => ({ ...job, value: index + 1 })) + }, + consume: () => Number(labels.has(500)) + retry().id + jobs.get(iterations % size)!().value, + } +} + +function indexedValueUpdate(): Workload { + const jobs = Jobs.make(initial) + return { + run(index) { + const id = index % size + jobs.modify(id, (job) => ({ ...job, value: index + 1 })) + }, + consume: () => + Number(jobs.hasMember("labels", 500)) + jobs.first("retry")()!.id + jobs.get(iterations % size)!().value, + } +} + +function manualMemberAppend(): Workload { + const jobs = JobPlan.make(initial) + const labels = new Map(initial.flatMap((job) => job.labels.map((label) => [label, 1]))) + const members = new Map(initial.map((job) => [job.id, new Set(job.labels)])) + return { + run(index) { + const id = index % size + const label = size + index + jobs.modify(id, (job) => { + const next = [...job.labels.slice(-7), label] + const previous = members.get(id)! + const current = new Set(next) + previous.forEach((member) => { + if (current.has(member)) return + const count = labels.get(member)! + if (count === 1) labels.delete(member) + if (count > 1) labels.set(member, count - 1) + }) + current.forEach((member) => { + if (!previous.has(member)) labels.set(member, (labels.get(member) ?? 0) + 1) + }) + members.set(id, current) + return { ...job, labels: next } + }) + }, + consume: () => Number(labels.has(size + iterations - 1)) + jobs.get(iterations % size)!().labels.length, + } +} + +function indexedMemberAppend(): Workload { + const jobs = LabeledJobs.make(initial) + return { + run(index) { + const id = index % size + const label = size + index + jobs.modify(id, (job) => ({ + ...job, + labels: [...job.labels.slice(-7), label], + })) + }, + consume: () => + Number(jobs.hasMember("labels", size + iterations - 1)) + jobs.get(iterations % size)!().labels.length, + } +} + +function manualGrowingAppend(): Workload { + const jobs = JobPlan.make([{ id: 0, labels: [], status: "running", value: 0 }]) + const labels = new Set() + let label = 0 + return { + run() { + const next = label++ + jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] })) + labels.add(next) + }, + consume: () => Number(labels.has(label - 1)) + jobs.get(0)!().labels.length, + } +} + +function indexedGrowingAppend(): Workload { + const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }]) + let label = 0 + return { + run() { + const next = label++ + jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }), { + members: { labels: { add: [next] } }, + }) + }, + consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length, + } +} + +function automaticGrowingAppend(): Workload { + const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }]) + let label = 0 + return { + run() { + const next = label++ + jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] })) + }, + consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length, + } +} + +function collectionSet(indexed: boolean, changed: boolean): Workload { + const jobs = indexed ? Jobs.make(initial) : JobPlan.make(initial) + return { + run(index) { + const id = index % size + const current = jobs.values() + const job = current[id] + jobs.set(current.with(id, { ...job, value: changed ? job.value + 1 : job.value })) + }, + consume: () => jobs.values()[0].value + jobs.values().length, + } +} + +console.log(`Compiled collection benchmark (${size} items, ${bench.samples} samples)\n`) + +const value = bench.compare(iterations, [ + { name: "Handwritten value update", make: manualValueUpdate }, + { name: "Compiled indexed value update", make: indexedValueUpdate }, +]) +const member = bench.compare(iterations, [ + { name: "Handwritten member append", make: manualMemberAppend }, + { name: "Compiled indexed member append", make: indexedMemberAppend }, +]) +const growing = bench.compare(2_000, [ + { name: "Handwritten growing append", make: manualGrowingAppend }, + { name: "Automatic indexed growing append", make: automaticGrowingAppend }, + { name: "Indexed delta growing append", make: indexedGrowingAppend }, +]) +const equivalentSet = bench.compare(2_000, [ + { name: "Bare keyed equivalent set", make: () => collectionSet(false, false) }, + { name: "Compiled indexed equivalent set", make: () => collectionSet(true, false) }, +]) +const changedSet = bench.compare(2_000, [ + { name: "Bare keyed changed set", make: () => collectionSet(false, true) }, + { name: "Compiled indexed changed set", make: () => collectionSet(true, true) }, +]) + +console.log("\nRatios to handwritten (lower is faster)") +console.log(`Value update: ${value.ratio(1, 0).toFixed(3)}x`) +console.log(`Member append: ${member.ratio(1, 0).toFixed(3)}x`) +console.log(`Automatic growing append: ${growing.ratio(1, 0).toFixed(3)}x`) +console.log(`Delta growing append: ${growing.ratio(2, 0).toFixed(3)}x`) +console.log(`Equivalent collection set: ${equivalentSet.ratio(1, 0).toFixed(3)}x`) +console.log(`Changed collection set: ${changedSet.ratio(1, 0).toFixed(3)}x`) +console.log(`METRIC collection_value_ratio=${value.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC collection_member_ratio=${member.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC collection_automatic_growing_ratio=${growing.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC collection_delta_growing_ratio=${growing.ratio(2, 0).toFixed(6)}`) +console.log(`METRIC collection_equivalent_set_ratio=${equivalentSet.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC collection_changed_set_ratio=${changedSet.ratio(1, 0).toFixed(6)}`) +bench.finish() diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts index 575aa196bb01..c876968cffc8 100644 --- a/packages/quark/src/keyed.ts +++ b/packages/quark/src/keyed.ts @@ -9,19 +9,23 @@ export namespace Keyed { equivalenceSuppressions: number } - export interface Keyed { + /** Read surface of a keyed collection; hand this to consumers that must not mutate. */ + export interface ReadOnly { readonly slots: Readable[]> readonly values: Readable has(key: Key): boolean get(key: Key): Readable | undefined + before(key: Key): Readable | undefined + after(key: Key): Readable | undefined + } + + export interface Keyed extends ReadOnly { set(values: readonly A[]): boolean update(value: A): boolean modify(key: Key, f: (value: A) => A): boolean insert(value: A, position?: Position): Readable remove(key: Key): boolean move(key: Key, position?: Position): boolean - before(key: Key): Readable | undefined - after(key: Key): Readable | undefined } export function make(options: { diff --git a/packages/quark/src/layout.ts b/packages/quark/src/layout.ts index 662006f09eac..c50bb52240fe 100644 --- a/packages/quark/src/layout.ts +++ b/packages/quark/src/layout.ts @@ -1,5 +1,5 @@ import { Keyed } from "./keyed" -import { Transaction, type Readable } from "./reactivity" +import { Computed, State, Transaction, type Readable } from "./reactivity" export namespace Layout { export interface Field { @@ -41,10 +41,8 @@ export namespace Layout { readonly fields: StructFields } - export interface Union< - Tag extends PropertyKey, - Variants extends Readonly>>, - > extends Field> { + export interface Union>>> + extends Field> { readonly type: "union" readonly tag: Tag readonly variants: Variants @@ -135,7 +133,8 @@ export namespace Layout { export interface Collection> extends Keyed.Keyed { modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges }): boolean hasMember>(name: Name, member: Member): boolean - first>(name: Name): Readable | undefined + /** Reactive first match of the named index; publishes when the first match changes identity or value. */ + first>(name: Name): Readable } export interface CollectionPlan> extends Plan { @@ -223,7 +222,8 @@ export namespace Layout { >(layout: KeyedUnion, options?: CompileOptions): Plan, A> export function compile(input: unknown, options: CompileOptions = {}): unknown { const layout = input as - Struct | KeyedUnion>>> + | Struct + | KeyedUnion>>> if (layout.type === "keyed-union") { const model = unionDiffModel(layout.key.name, layout.tag, layout.variants) return makePlan( @@ -276,11 +276,17 @@ export namespace Layout { export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown { const plan = compile(input as never, options) as Plan const definitions = (define as (index: IndexBuilder) => Indexes)({ - members: ((fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable), extract?: (value: unknown) => Iterable) => + members: (( + fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable), + extract?: (value: unknown) => Iterable, + ) => typeof fieldsOrExtract === "function" ? { type: "members", extract: fieldsOrExtract } : { type: "members", fields: fieldsOrExtract, extract: extract! }) as IndexBuilder["members"], - first: ((fieldsOrMatches: readonly PropertyKey[] | ((value: unknown) => boolean), matches?: (value: unknown) => boolean) => + first: (( + fieldsOrMatches: readonly PropertyKey[] | ((value: unknown) => boolean), + matches?: (value: unknown) => boolean, + ) => typeof fieldsOrMatches === "function" ? { type: "first", matches: fieldsOrMatches } : { type: "first", fields: fieldsOrMatches, matches: matches! }) as IndexBuilder["first"], @@ -293,6 +299,28 @@ export namespace Layout { } } + /** + * Explicit-target collection compiler for keyed-union layouts whose derived + * value type intentionally under-describes the real one (e.g. reference + * fields typed `unknown`, optional fields omitted). The layout's key name, + * key type, and tag values are checked against `Target`; field-level shapes + * are trusted at this single boundary. + */ + export function collectionOf() { + return function < + const Name extends keyof Target & PropertyKey, + const Tag extends keyof Target & PropertyKey, + const Variants extends Readonly, Field>>, + const Definitions extends Indexes, + >( + layout: KeyedUnion, + define: (index: IndexBuilder) => Definitions, + options?: CompileOptions, + ): CollectionPlan { + return collection(layout as never, define as never, options) as CollectionPlan + } + } + function makePlan( key: PropertyKey, getKey: (value: A) => Key, @@ -358,7 +386,7 @@ export namespace Layout { afterPlacement?(slot: Readable): void afterRebuild?(): void member?(candidate: unknown): boolean - firstSlot?(): Readable | undefined + readonly first?: Readable } function membersEntry( @@ -421,29 +449,34 @@ export namespace Layout { matches: (value: A) => boolean, ): Entry { const matching = new Set() - let slot: Readable | undefined + // The current first-matching slot is reactive state so `first` readers + // observe identity changes; the flattening computed below also tracks + // the slot itself, so value changes of the first match publish too. + const slot = State.make | undefined>(undefined) + const first = Computed.make(() => slot()?.()) function findFirst() { - slot = values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))) + slot.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate())))) } function afterPlacement(candidate: Readable) { if (!matching.has(plan.keyOf(candidate()))) return - if (!slot) { - slot = candidate + const current = slot() + if (!current) { + slot.set(candidate) return } - if (slot === candidate) { + if (current === candidate) { findFirst() return } // Single pass: whichever of the two slots appears first wins. - for (const current of values.slots()) { - if (current === candidate) { - slot = candidate + for (const item of values.slots()) { + if (item === candidate) { + slot.set(candidate) return } - if (current === slot) return + if (item === current) return } } @@ -457,20 +490,20 @@ export namespace Layout { if (matched) matching.add(key) if (!matched) matching.delete(key) if (mode === "add") return - if (slot === valueSlot && !matched) { + if (slot() === valueSlot && !matched) { findFirst() return } - if (slot !== valueSlot && !previous && matched) afterPlacement(valueSlot) + if (slot() !== valueSlot && !previous && matched) afterPlacement(valueSlot) } }, remove(key, removedSlot) { matching.delete(key) - if (removedSlot && slot === removedSlot) findFirst() + if (removedSlot && slot() === removedSlot) findFirst() }, afterPlacement, afterRebuild: findFirst, - firstSlot: () => slot, + first, } } @@ -628,7 +661,9 @@ export namespace Layout { return byName.get(normalizeName(name))?.member?.(member) ?? false }, first(name) { - return byName.get(normalizeName(name))?.firstSlot?.() + const entry = byName.get(normalizeName(name)) + if (!entry?.first) throw new Error(`Unknown first index: ${String(name)}`) + return entry.first }, } collection.set(initial) diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts index 2ffdd3dc748e..c0801e3d41f8 100644 --- a/packages/quark/src/reactivity.ts +++ b/packages/quark/src/reactivity.ts @@ -1,3 +1,11 @@ +/** + * A callable reactive value. + * + * `subscribe`, `set`, and `update` are shared this-based methods, not + * per-instance closures: invoke them as methods (`readable.subscribe(f)`) or + * bind before detaching (`readable.subscribe.bind(readable)`). A detached + * bare reference loses its node and throws. + */ export interface Readable { (): A subscribe(listener: (value: A) => void): () => void @@ -188,8 +196,7 @@ function readComputed(node: ComputedNode): A { } if ( flags & Flags.Dirty || - (flags & Flags.Pending && - (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) || + (flags & Flags.Pending && (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) || !flags ) { if (updateComputed(node) && node.subs !== undefined) { @@ -255,8 +262,7 @@ function runWatcher(watcher: WatcherNode): void { const flags = watcher.flags watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending) if (!(flags & Flags.Watching)) return - const dirty = - !!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher)) + const dirty = !!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher)) // Revalidation runs user code in computed evaluations, which may dispose // this watcher; a disposed watcher must not deliver. if (!dirty || !(watcher.flags & Flags.Watching)) return diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts index 9acd6bcfffc3..47bebfef92a6 100644 --- a/packages/quark/src/solid.ts +++ b/packages/quark/src/solid.ts @@ -10,12 +10,17 @@ export function useValue(readable: Readable): Accessor { * Reactive accessor for one keyed slot. Structural changes re-resolve the * slot, while memo equality prevents an unchanged slot from propagating to the * consumer. Value changes flow through the slot itself. + * + * The key is usually constant and may be passed plainly; pass an accessor when + * the key itself is reactive. (A key that is itself a function value is + * indistinguishable from an accessor and must be wrapped.) */ -export function useSlot(keyed: Pick, "slots" | "get">, key: () => Key): Accessor { +export function useSlot(keyed: Keyed.ReadOnly, key: Key | (() => Key)): Accessor { + const resolve = typeof key === "function" ? (key as () => Key) : () => key const structure = useValue(keyed.slots) const slot = createMemo(() => { structure() - return keyed.get(key()) + return keyed.get(resolve()) }) const value = createMemo(() => { const current = slot() diff --git a/packages/quark/test/layout.test.ts b/packages/quark/test/layout.test.ts index 91e5f66ad622..2377eec49603 100644 --- a/packages/quark/test/layout.test.ts +++ b/packages/quark/test/layout.test.ts @@ -224,19 +224,40 @@ describe("Layout", () => { expect(jobs.hasMember("labels", "billing")).toBe(true) expect(jobs.hasMember("labels", "missing")).toBe(false) - expect(jobs.first("nextRetry")?.().id).toBe("two") + expect(jobs.first("nextRetry")()?.id).toBe("two") expect(jobs.before("two")?.().id).toBe("one") expect(jobs.after("two")?.().id).toBe("three") jobs.modify("one", (job) => ({ ...job, labels: [], status: "retrying" })) expect(jobs.hasMember("labels", "urgent")).toBe(false) expect(jobs.hasMember("labels", "billing")).toBe(true) - expect(jobs.first("nextRetry")?.().id).toBe("one") + expect(jobs.first("nextRetry")()?.id).toBe("one") jobs.remove("two") expect(jobs.hasMember("labels", "billing")).toBe(false) jobs.move("three", { before: "one" }) - expect(jobs.first("nextRetry")?.().id).toBe("three") + expect(jobs.first("nextRetry")()?.id).toBe("three") + }) + + it("publishes first-index changes to subscribers", () => { + const jobs = Jobs.make([ + { id: "one", labels: [], status: "running" }, + { id: "two", labels: [], status: "retrying" }, + ]) + const seen: (string | undefined)[] = [] + const dispose = jobs.first("nextRetry").subscribe((job) => seen.push(job?.id)) + + // Identity change: a match earlier in slot order becomes the first. + jobs.modify("one", (job) => ({ ...job, status: "retrying" })) + // Value change of the current first match publishes through the slot. + jobs.modify("one", (job) => ({ ...job, labels: ["late"] })) + // The first match stops matching; the next one takes over. + jobs.modify("one", (job) => ({ ...job, status: "done" })) + // No match left. + jobs.remove("two") + + expect(seen).toEqual(["one", "one", "two", undefined]) + dispose() }) it("keeps indexes synchronized across inserts, updates, and replacement", () => { @@ -244,14 +265,14 @@ describe("Layout", () => { jobs.insert({ id: "three", labels: ["shared"], status: "retrying" }) jobs.insert({ id: "two", labels: ["shared"], status: "retrying" }, { before: "three" }) - expect(jobs.first("nextRetry")?.().id).toBe("two") + expect(jobs.first("nextRetry")()?.id).toBe("two") jobs.update({ id: "two", labels: [], status: "done" }) - expect(jobs.first("nextRetry")?.().id).toBe("three") + expect(jobs.first("nextRetry")()?.id).toBe("three") expect(jobs.hasMember("labels", "shared")).toBe(true) jobs.remove("three") - expect(jobs.first("nextRetry")).toBeUndefined() + expect(jobs.first("nextRetry")()).toBeUndefined() expect(jobs.hasMember("labels", "shared")).toBe(false) jobs.set([ @@ -260,7 +281,7 @@ describe("Layout", () => { ]) expect(jobs.hasMember("labels", "replacement")).toBe(true) expect(jobs.hasMember("labels", "one")).toBe(false) - expect(jobs.first("nextRetry")?.().id).toBe("four") + expect(jobs.first("nextRetry")()?.id).toBe("four") }) it("skips index projection when the change is disjoint from its declared fields", () => { @@ -284,7 +305,7 @@ describe("Layout", () => { jobs.update({ id: "one", labels, status: "retrying" }) expect(extractions).toBe(baseline.extractions) expect(matchChecks).toBe(baseline.matchChecks + 1) - expect(jobs.first("nextRetry")?.().id).toBe("one") + expect(jobs.first("nextRetry")()?.id).toBe("one") // Labels-only change: the first-match check reads only `status`, so it skips. jobs.update({ id: "one", labels: ["urgent"], status: "retrying" }) @@ -340,14 +361,11 @@ describe("Layout", () => { const jobs = CountedJobs.make([one, two]) comparisons = 0 - jobs.set([ - { ...one }, - { ...two, value: "after" }, - ]) + jobs.set([{ ...one }, { ...two, value: "after" }]) expect(comparisons).toBe(4) expect(jobs.get("one")?.()).toBe(one) - expect(jobs.first("changed")?.().id).toBe("two") + expect(jobs.first("changed")()?.id).toBe("two") comparisons = 0 jobs.update({ ...jobs.get("one")!(), status: "busy" }) @@ -356,7 +374,7 @@ describe("Layout", () => { comparisons = 0 jobs.modify("two", (job) => ({ ...job, value: "done" })) expect(comparisons).toBe(1) - expect(jobs.first("changed")).toBeUndefined() + expect(jobs.first("changed")()).toBeUndefined() }) it("refreshes indexes that read a changed union discriminant", () => { @@ -379,7 +397,7 @@ describe("Layout", () => { expect(rows.get(1)?.().type).toBe("ready") expect(rows.hasMember("types", "waiting")).toBe(false) expect(rows.hasMember("types", "ready")).toBe(true) - expect(rows.first("firstReady")?.().type).toBe("ready") + expect(rows.first("firstReady")()?.type).toBe("ready") }) it("tracks lazy member iterables while they are consumed", () => { @@ -470,7 +488,7 @@ describe("Layout", () => { expect(jobs.values()).toEqual([{ id: "one", labels: ["safe"], status: "running" }]) expect(jobs.hasMember("labels", "safe")).toBe(true) expect(jobs.hasMember("labels", "boom")).toBe(false) - expect(jobs.first("nextRetry")).toBeUndefined() + expect(jobs.first("nextRetry")()).toBeUndefined() expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow( "Keyed value does not exist: missing", diff --git a/packages/quark/test/solid.test.ts b/packages/quark/test/solid.test.ts index 43ebaab0d025..06a87ae85b32 100644 --- a/packages/quark/test/solid.test.ts +++ b/packages/quark/test/solid.test.ts @@ -33,7 +33,7 @@ describe("Solid adapter", () => { createRoot((dispose) => { const current = useValue(state) - createComputed(() => value = current()) + createComputed(() => (value = current())) state.set(second) dispose() }) @@ -41,6 +41,21 @@ describe("Solid adapter", () => { expect(value).toBe(second) }) + it("accepts a plain constant key", () => { + const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id }) + keyed.set([{ id: 1, value: "one" }]) + const values: Array = [] + + createRoot((dispose) => { + const value = useSlot(keyed, 1) + createComputed(() => values.push(value()?.value)) + keyed.modify(1, (item) => ({ ...item, value: "ONE" })) + dispose() + }) + + expect(values).toEqual(["one", "ONE"]) + }) + it("switches keyed slots and releases obsolete subscriptions", () => { const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id }) keyed.set([ diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts index eaff9c656034..8182bc9e78d7 100644 --- a/packages/tui/src/routes/session/content.ts +++ b/packages/tui/src/routes/session/content.ts @@ -1,5 +1,6 @@ import type { SessionMessageAssistant } from "@opencode-ai/client" import { Keyed, Layout } from "@opencode-ai/quark" +import { toolDisplay } from "../../util/tool-display" /** * Stable per-part reactive slots for assistant message content. @@ -12,9 +13,9 @@ import { Keyed, Layout } from "@opencode-ai/quark" export namespace SessionContent { type ContentPart = SessionMessageAssistant["content"][number] export type Part = ContentPart & { readonly partID: string } - export type Parts = Keyed.Keyed + export type Parts = ReturnType /** Read surface for view components; mutation stays with the data layer. */ - export type PartsView = Pick + export type PartsView = Keyed.ReadOnly & Pick // Streamed sub-objects are replaced immutably on change, so reference // equality is the correct (and cheapest) field comparator for them. @@ -38,8 +39,16 @@ export namespace SessionContent { }, }) // The layout describes the reactive fields of the client content shapes; - // the plan is typed against those shapes at this single boundary. - const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan + // collectionOf types the plan against those shapes at this single boundary. + const PartPlan = Layout.collectionOf()(PartLayout, ({ first }) => ({ + // First running shell/subagent tool; drives the background-work hint + // without subscribing views to whole-collection values. + backgroundRunning: first(["type", "state"], (part) => { + if (part.type !== "tool" || part.state.status !== "running") return false + const display = toolDisplay(part.name) + return display === "shell" || display === "subagent" + }), + })) export function textID(ordinal: number) { return `text:${ordinal}` diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0929c4147a88..c7f9b0c5ccd4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -41,7 +41,7 @@ import type { import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" import { FilePath } from "../../ui/file-path" -import { webSearchProviderLabel } from "../../util/tool-display" +import { toolDisplay, webSearchProviderLabel } from "../../util/tool-display" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useClient } from "../../context/client" import { useEditorContext } from "../../context/editor" @@ -1088,24 +1088,13 @@ function BackgroundToolHint(props: { (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, ), ) - // Track the part structure (publishes only on part insert/remove) and the - // individual tool slots; text and reasoning deltas never reach this memo. - const toolSlots = createMemo(() => { + // The collection maintains the first-match index; text and reasoning deltas + // never publish it, so this memo re-runs only on background-tool changes. + const visible = createMemo(() => { const message = current() - if (!message) return [] - const parts = props.parts(message.id) - return useValue(parts.slots)() - .filter((slot) => slot().type === "tool") - .map((slot) => useValue(slot)) + if (!message) return false + return useValue(props.parts(message.id).first("backgroundRunning"))() !== undefined }) - const visible = createMemo(() => - toolSlots().some((tool) => { - const part = tool() - if (part.type !== "tool" || part.state.status !== "running") return false - const display = toolDisplay(part.name) - return display === "shell" || display === "subagent" - }), - ) return ( {(value) => ( @@ -1150,7 +1139,7 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { // instead of rebuilding the whole accessor list; value changes flow through // the individual slots. function usePartSlots(parts: (messageID: string) => SessionContent.PartsView, refs: () => readonly PartRef[]) { - return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), () => ref.partID) })) + return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), ref.partID) })) } function SessionPartView(props: { @@ -1266,7 +1255,7 @@ function SessionReasoningGroupView(props: { {(ref) => { - const slot = useSlot(props.parts(ref.messageID), () => ref.partID) + const slot = useSlot(props.parts(ref.messageID), ref.partID) const part = createMemo(() => { const item = slot() return item?.type === "reasoning" ? item : undefined @@ -2919,29 +2908,6 @@ function numberValue(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : undefined } -const toolDisplays = new Set([ - "shell", - "glob", - "read", - "grep", - "webfetch", - "websearch", - "write", - "edit", - "subagent", - "execute", - "patch", - "question", - "skill", -]) - -export function toolDisplay(tool: string) { - // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render - // them with the renamed views. - const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool - return toolDisplays.has(normalized) ? normalized : "generic" -} - function recordValue(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return return value as Record diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 9f596a017ed4..295caa30ed32 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -24,7 +24,7 @@ export function usePermissionInput(request: PermissionV2Request): () => Record ({}) - const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), () => tool.callID) + const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), tool.callID) return createMemo(() => { const item = part() if (item?.type === "tool" && item.state.status !== "streaming") return item.state.input diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index c9d92434e206..eed92c5c8efc 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -1,3 +1,26 @@ +const toolDisplays = new Set([ + "shell", + "glob", + "read", + "grep", + "webfetch", + "websearch", + "write", + "edit", + "subagent", + "execute", + "patch", + "question", + "skill", +]) + +export function toolDisplay(tool: string) { + // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render + // them with the renamed views. + const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool + return toolDisplays.has(normalized) ? normalized : "generic" +} + export function webSearchProviderLabel(provider: unknown) { if (provider === "parallel") return "Parallel Web Search" if (provider === "exa") return "Exa Web Search" diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 1f44f9260048..4b77df974f5d 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -9,8 +9,8 @@ import { parseDiagnostics, parseQuestionAnswers, parseQuestions, - toolDisplay, } from "../../../src/routes/session" +import { toolDisplay } from "../../../src/util/tool-display" let testSetup: Awaited> | undefined From 5b77532fd2499679a80cf076a5be1bbe410ce8ac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 10:28:56 -0400 Subject: [PATCH 17/18] chore(tui): sync lockfile with quark package rename --- bun.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 2fe84ea4c51b..6191f00b3699 100644 --- a/bun.lock +++ b/bun.lock @@ -779,7 +779,7 @@ }, }, "packages/quark": { - "name": "effect-quark", + "name": "@opencode-ai/quark", "version": "0.0.0", "dependencies": { "solid-js": "catalog:", @@ -1033,6 +1033,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/quark": "workspace:*", "@opencode-ai/simulation": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", @@ -1042,7 +1043,6 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", - "effect-quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", @@ -2241,6 +2241,8 @@ "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + "@opencode-ai/quark": ["@opencode-ai/quark@workspace:packages/quark"], + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -4047,8 +4049,6 @@ "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=="], From 196892c165b0f40aa0957e0171afaedc42920d6d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 10:55:17 -0400 Subject: [PATCH 18/18] fix(tui): follow the current permission request in usePermissionInput The prompt stays mounted while requests queue, so the hook must take a request accessor; capturing the first request left the input subscribed to the resolved tool call. Regression test swaps the mounted request. --- .../tui/src/routes/session/permission.tsx | 20 ++++--- packages/tui/test/cli/tui/permission.test.tsx | 53 +++++++++++++++---- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 295caa30ed32..820e88776fcc 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -19,14 +19,20 @@ import { useSlot } from "@opencode-ai/quark/solid" type PermissionStage = "permission" | "always" | "reject" -/** Resolved tool input for a permission request, once input streaming settles. */ -export function usePermissionInput(request: PermissionV2Request): () => Record { +/** + * Resolved tool input for a permission request, once input streaming settles. + * Takes an accessor: the prompt stays mounted across queued requests, so the + * slot subscription must follow the current request. + */ +export function usePermissionInput(request: () => PermissionV2Request): () => Record { const data = useData() - const tool = request.source - if (!tool) return () => ({}) - const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), tool.callID) + const part = createMemo(() => { + const tool = request().source + if (!tool) return + return useSlot(data.session.message.parts(request().sessionID, tool.messageID), tool.callID) + }) return createMemo(() => { - const item = part() + const item = part()?.() if (item?.type === "tool" && item.state.status !== "streaming") return item.state.input return {} }) @@ -157,7 +163,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const pathFormatter = usePathFormatter() const session = createMemo(() => data.session.get(props.request.sessionID)) - const input = usePermissionInput(props.request) + const input = usePermissionInput(() => props.request) const { themeV2 } = useTheme() diff --git a/packages/tui/test/cli/tui/permission.test.tsx b/packages/tui/test/cli/tui/permission.test.tsx index fe89af4cb04f..7e70b1a04403 100644 --- a/packages/tui/test/cli/tui/permission.test.tsx +++ b/packages/tui/test/cli/tui/permission.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client" -import { createEffect, type ParentProps } from "solid-js" +import { createEffect, createSignal, type ParentProps } from "solid-js" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" @@ -50,15 +50,18 @@ test("permission input tracks the tool part slot as input settles", async () => let client!: ReturnType let input!: () => unknown - const request = { - id: "perm_1", - sessionID, - permission: "shell", - resources: [], - metadata: {}, - source: { messageID: "message-assistant", callID: "call-permission" }, - time: { created: 1 }, - } as unknown as PermissionV2Request + const permissionRequest = (id: string, callID: string) => + ({ + id, + sessionID, + permission: "shell", + resources: [], + metadata: {}, + source: { messageID: "message-assistant", callID }, + time: { created: 1 }, + }) as unknown as PermissionV2Request + + const [request, setRequest] = createSignal(permissionRequest("perm_1", "call-permission")) function Probe() { data = useData() @@ -109,6 +112,36 @@ test("permission input tracks the tool part slot as input settles", async () => const value = input() return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist" }) + + // The prompt stays mounted while requests queue: when the next request + // becomes current, the input must re-resolve against its tool call. + emitEvent(events, { + id: "evt_perm_tool_started_2", + created: 3, + type: "session.tool.input.started", + durable: { aggregateID: `session_${sessionID}`, seq: 2, version: 1 }, + data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission-2", name: "shell" }, + } as unknown as OpenCodeEvent) + emitEvent(events, { + id: "evt_perm_tool_called_2", + created: 4, + type: "session.tool.called", + durable: { aggregateID: `session_${sessionID}`, seq: 3, version: 1 }, + data: { + sessionID, + assistantMessageID: "message-assistant", + callID: "call-permission-2", + input: { command: "git push" }, + timestamp: 4, + }, + } as unknown as OpenCodeEvent) + await wait(() => data.session.message.parts(sessionID, "message-assistant").has("call-permission-2")) + + setRequest(permissionRequest("perm_2", "call-permission-2")) + await wait(() => { + const value = input() + return typeof value === "object" && value !== null && (value as { command?: string }).command === "git push" + }) } finally { app.renderer.destroy() }