diff --git a/bun.lock b/bun.lock index 7b3bfa74520c..8d512c90ee78 100644 --- a/bun.lock +++ b/bun.lock @@ -778,6 +778,13 @@ "typescript": "catalog:", }, }, + "packages/quark": { + "name": "@opencode-ai/quark", + "version": "0.0.0", + "dependencies": { + "solid-js": "catalog:", + }, + }, "packages/schema": { "name": "@opencode-ai/schema", "version": "1.17.11", @@ -1029,6 +1036,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:", @@ -2242,6 +2250,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"], 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/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/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..642d1593d68e --- /dev/null +++ b/packages/quark/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/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": { + "solid-js": "catalog:" + } +} diff --git a/packages/quark/src/index.ts b/packages/quark/src/index.ts new file mode 100644 index 000000000000..dd9db60faeb3 --- /dev/null +++ b/packages/quark/src/index.ts @@ -0,0 +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 new file mode 100644 index 000000000000..c876968cffc8 --- /dev/null +++ b/packages/quark/src/keyed.ts @@ -0,0 +1,177 @@ +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 + } + + /** 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 + } + + 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") + + return Transaction.run(() => { + let changed = false + 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 (publish(slot, value)) changed = true + return slot + }) + // 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++ + changed = true + } + return changed + }) + }, + update(value) { + 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) 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) + }, + 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) + // 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 + 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) 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 + const next = current.slice() + next.splice(from, 1) + next.splice(to, 0, slot) + slots.set(next) + 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) { + const current = slot() + // The reactive kernel uses strict identity for primitive writes. + if (current === value || equivalent(current, value)) { + if (options.metrics) options.metrics.equivalenceSuppressions++ + return false + } + slot.set(value) + if (options.metrics) options.metrics.slotPublications++ + return true + } + + 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) + } + + function neighbor(key: Key, offset: -1 | 1) { + const slot = byKey.get(key) + if (!slot) return undefined + const current = slots() + return current[current.indexOf(slot) + offset] + } + } + + 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/layout.ts b/packages/quark/src/layout.ts new file mode 100644 index 000000000000..c50bb52240fe --- /dev/null +++ b/packages/quark/src/layout.ts @@ -0,0 +1,966 @@ +import { Keyed } from "./keyed" +import { Computed, State, 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 + /** 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 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 + } + + 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 + } + + 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[] + } + } + + /** + * Comparator backend. "generated" (the default) compiles one specialized + * Function per plan operation; "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 + 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 { + modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges }): boolean + hasMember>(name: Name, member: Member): boolean + /** 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 { + 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() + + interface ArrayField extends Field { + readonly type: "array" + readonly item: Field + } + + export function array(item: Field): Field { + 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 { + 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?: CompileOptions, + ): Plan, Value[KeyName]> + export function compile< + const Name extends PropertyKey, + A, + const Tag extends PropertyKey, + const Variants extends Readonly>>, + >(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 !== "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, + ) + } + + 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, 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 !== "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, diff, bits), + } + } + + export function collection>>( + layout: Struct, + define: (index: IndexBuilder>) => Definitions, + options?: CompileOptions, + ): 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?: CompileOptions, + ): CollectionPlan, A, Definitions> + 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, + ) => + 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, + make(initial: readonly unknown[] = [], makeOptions?: { readonly metrics?: Keyed.Metrics }) { + return makeCollection(plan, definitions, initial, makeOptions) + }, + } + } + + /** + * 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, + 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, + }) + values.set(initial) + return values + }, + } + } + + function makeCollection>( + plan: Plan, + definitions: Definitions, + initial: readonly A[], + options?: { readonly metrics?: Keyed.Metrics }, + ): 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: { readonly previous: A; readonly next: A; readonly mask: number } | undefined + const values = Keyed.make({ + key: plan.keyOf, + equivalent(left, right) { + 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, + }) + + 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 + // (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 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 + member?(candidate: unknown): boolean + readonly first?: Readable + } + + function membersEntry( + name: PropertyKey, + fields: readonly PropertyKey[] | undefined, + extract: (value: A) => Iterable, + ): Entry { + const counts = new Map() + const byKey = new Map; readonly members: Set }>() + + 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: dependencyMask(fields), + stage(key, value) { + const previous = byKey.get(key) + const source = extract(value) + const members = source === previous?.source ? previous.members : new Set(source) + return () => { + 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, so poison the source-identity check. + byKey.set(key, { source: members, members }) + }, + remove(key) { + byKey.get(key)?.members.forEach((member) => adjust(member, -1)) + byKey.delete(key) + }, + member: (candidate) => counts.has(candidate), + } + } + + function firstEntry( + name: PropertyKey, + fields: readonly PropertyKey[] | undefined, + matches: (value: A) => boolean, + ): Entry { + const matching = new Set() + // 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.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate())))) + } + + function afterPlacement(candidate: Readable) { + if (!matching.has(plan.keyOf(candidate()))) return + const current = slot() + if (!current) { + slot.set(candidate) + return + } + if (current === candidate) { + findFirst() + return + } + // Single pass: whichever of the two slots appears first wins. + for (const item of values.slots()) { + if (item === candidate) { + slot.set(candidate) + return + } + if (item === current) return + } + } + + return { + name, + reads: dependencyMask(fields), + stage(key, value, mode) { + const matched = matches(value) + return (valueSlot) => { + 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) + if (removedSlot && slot() === removedSlot) findFirst() + }, + afterPlacement, + afterRebuild: findFirst, + first, + } + } + + const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => { + const definition = definitions[name] + 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 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 + 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 + } + if ((mask & entry.reads) === 0) continue + ;(staged ??= []).push(entry.stage(key, value, "replace")) + } + return staged + } + + 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. + if (!staged) return publishValue() + return Transaction.run(() => { + const changed = publishValue() + if (changed) staged.forEach((commit) => commit(slot)) + return changed + }) + + function publishValue() { + pending = { previous, next: value, mask } + try { + return values.update(value) + } finally { + pending = undefined + } + } + } + + const collection: Collection = { + ...values, + set(next) { + const keys = next.map(plan.keyOf) + 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) + }) + // 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 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 structure = values.slots() + return Transaction.run(() => { + 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)) + }) + 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 previous = slot() + if (previous === value) return false + const mask = plan.diff(previous, value) + const staged = mask === 0 ? undefined : stageRefresh(key, value, mask) + return publishStaged(previous, value, mask, staged, slot) + }, + modify(key, f, changes) { + const slot = values.get(key) + if (!slot) return false + 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") + if (previous === value) return false + const mask = plan.diff(previous, value) + const staged = mask === 0 ? undefined : stageRefresh(key, value, mask, changes?.members) + return publishStaged(previous, value, mask, staged, slot) + }, + insert(value, position) { + const key = plan.keyOf(value) + if (values.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`) + requirePosition(position) + const staged = entries.map((entry) => entry.stage(key, value, "add")) + return Transaction.run(() => { + const slot = values.insert(value, position) + staged.forEach((commit) => commit(slot)) + entries.forEach((entry) => entry.afterPlacement?.(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) => entry.remove(key, slot)) + return removed + }) + }, + move(key, position) { + const slot = values.get(key) + if (!slot) return false + return Transaction.run(() => { + const moved = values.move(key, position) + if (moved) entries.forEach((entry) => entry.afterPlacement?.(slot)) + return moved + }) + }, + hasMember(name, member) { + return byName.get(normalizeName(name))?.member?.(member) ?? false + }, + first(name) { + const entry = byName.get(normalizeName(name)) + if (!entry?.first) throw new Error(`Unknown first index: ${String(name)}`) + return entry.first + }, + } + collection.set(initial) + return collection + + 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 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] })), + ) + } + + // 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>) => (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, 1) + 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++ + 1, 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 variant = variants[name] + 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)) + 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>) => (left: unknown, right: unknown) => number + return factory(emitter.custom) + } + + // 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: Field[] + readonly declarations: string[] + readonly names: Map + } + + function generateEquivalent( + 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 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>>>( + tag: Tag, + variants: Variants, + ) { + if (typeof tag === "symbol") return compileUnion(tag, variants) + const emitter: Emitter = { custom: [], declarations: [], names: new Map() } + const root = declareUnion(emitter, { tag, variants }, tag, variants) + return assemble>(emitter, `${root}(left, right)`) + } + + function assemble(emitter: Emitter, body: string) { + const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as ( + custom: ReadonlyArray>, + ) => (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 && field.equivalent === Object.is) return `Object.is(${left}, ${right})` + const layout = field as Field & { + 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" && Reflect.ownKeys(layout.fields!).every((name) => typeof name !== "symbol")) { + const comparisons = Reflect.ownKeys(layout.fields!) + .filter((name) => !layout.fields![name].immutable) + .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 === "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.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) - 1 + return `custom[${index}].equivalent(${left}, ${right})` + } + + 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 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 + return (left: A, right: A) => { + 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 new file mode 100644 index 000000000000..c0801e3d41f8 --- /dev/null +++ b/packages/quark/src/reactivity.ts @@ -0,0 +1,498 @@ +/** + * 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 +} + +export interface Writable extends Readable { + set(value: A): void + update(f: (value: A) => A): void +} + +export namespace State { + export function make(initial: A): Writable { + const node: StateNode = { + flags: Flags.Mutable, + value: initial, + pending: initial, + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + } + // 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 + } +} + +export namespace Computed { + export function make(evaluate: (previous: A | undefined) => A): Readable { + const node: ComputedNode = { + flags: Flags.None, + value: undefined, + evaluate, + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + } + 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++ + try { + return f() + } finally { + 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/src/solid.ts b/packages/quark/src/solid.ts new file mode 100644 index 000000000000..47bebfef92a6 --- /dev/null +++ b/packages/quark/src/solid.ts @@ -0,0 +1,46 @@ +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. 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: 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(resolve()) + }) + const value = createMemo(() => { + const current = slot() + return current ? useValue(current) : undefined + }) + return () => value()?.() +} + +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..7fcd839e42cb --- /dev/null +++ b/packages/quark/test/keyed.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "bun:test" +import { Keyed } from "../src/keyed" +import { Computed } from "../src/reactivity" + +interface Item { + readonly id: number + readonly label: string +} + +const item = (id: number, label: string): Item => ({ id, label }) + +describe("Keyed", () => { + 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 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) + + keyed.set([item(1, "ONE"), item(2, "TWO")]) + expect(keyed.slots()).toBe(initial) + + keyed.set([item(2, "TWO"), item(1, "ONE")]) + + expect(structures).toEqual([[2, 1]]) + 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.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 slots for inserts and fresh slots after remove and reinsert", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + 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")]) + 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 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 equality for zero keys", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(-0, "negative zero")]) + const zero = keyed.slots()[0] + + keyed.set([item(0, "zero")]) + + 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 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"), item(3, "three")]) + const observations: string[] = [] + const summary = Computed.make(() => { + const slotKeys = keyed + .slots() + .map((slot) => slot().id) + .join(",") + const values = keyed + .values() + .map((value) => `${value.id}:${value.label}`) + .join(",") + return `${slotKeys}|${values}` + }) + const dispose = summary.subscribe((value) => observations.push(value)) + + keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")]) + + 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")]) + 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"))).toBe(false) + 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)).toBe(false) + }) + + 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() + expect(keyed.before(1)).toBeUndefined() + expect(keyed.after(1)).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.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) + 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]) + 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", () => { + 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, + }) + }) +}) diff --git a/packages/quark/test/layout.test.ts b/packages/quark/test/layout.test.ts new file mode 100644 index 000000000000..2377eec49603 --- /dev/null +++ b/packages/quark/test/layout.test.ts @@ -0,0 +1,561 @@ +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(["labels"], (job) => job.labels), + nextRetry: first(["status"], (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, { backend: "closure" }) + 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)) + 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 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, { backend: "closure" }) + 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("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", () => { + 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("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 }) => ({ + 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) { + jobs.modify("one", (job) => job, { + members: { + labels: { + // @ts-expect-error Member deltas require arrays so strings are not split into characters. + 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("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(["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", + }) + }) +}) diff --git a/packages/quark/test/reactivity.test.ts b/packages/quark/test/reactivity.test.ts new file mode 100644 index 000000000000..1c3b1e22242c --- /dev/null +++ b/packages/quark/test/reactivity.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from "bun:test" +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]) + }) + + it("does not track state read by subscription listeners", () => { + const source = State.make(1) + const unrelated = State.make(1) + const values: Array = [] + const dispose = source.subscribe((value) => { + unrelated() + values.push(value) + }) + + source.set(2) + unrelated.set(2) + + expect(values).toEqual([2]) + dispose() + }) + + 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 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 = () => {} + 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(() => a()).toThrow(/cycle/i) + }) +}) diff --git a/packages/quark/test/solid.test.ts b/packages/quark/test/solid.test.ts new file mode 100644 index 000000000000..06a87ae85b32 --- /dev/null +++ b/packages/quark/test/solid.test.ts @@ -0,0 +1,100 @@ +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("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([ + { 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.bind(slot) + slot.subscribe = (listener) => { + active[index]++ + const dispose = subscribe(listener) + return () => { + active[index]-- + dispose() + } + } + } + }) +}) diff --git a/packages/tui/bench/session-timeline.ts b/packages/tui/bench/session-timeline.ts new file mode 100644 index 000000000000..e3ff4ef7f4fb --- /dev/null +++ b/packages/tui/bench/session-timeline.ts @@ -0,0 +1,120 @@ +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" + +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 931c716d954b..4cbfbc3a6276 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" }, @@ -86,6 +87,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", + "@opencode-ai/quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 14990fb93373..d0f47e0644bc 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, @@ -19,9 +19,6 @@ import type { ReferenceInfo, SessionMessageInfo, SessionMessageAssistant, - SessionMessageAssistantReasoning, - SessionMessageAssistantText, - SessionMessageAssistantTool, SessionInfo, SessionPendingInfo, ShellInfo, @@ -33,10 +30,11 @@ 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" -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 @@ -71,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 @@ -82,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]) } @@ -131,7 +133,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ active: {}, message: {}, pending: {}, - input: {}, permission: {}, form: {}, }, @@ -146,19 +147,63 @@ 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() + // 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() 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", @@ -167,6 +212,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( @@ -199,19 +274,32 @@ 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, - ) + 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 }, + } + // 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", + status: "running", + reason: "manual", + summary: "", + recent: "", + time: { created: item.timeCreated }, + } }, } @@ -269,6 +357,8 @@ 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}`) @@ -281,7 +371,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)) { @@ -353,6 +442,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) @@ -374,59 +464,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 ( @@ -541,142 +607,109 @@ 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: "", - }) - }) + insertPart(event.data, { type: "text", text: "", partID: SessionContent.textID(event.data.ordinal) }) 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 - }) + modifyText(event.data, (part) => ({ ...part, text: part.text + event.data.delta })) 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 - }) + modifyText(event.data, (part) => ({ ...part, text: event.data.text })) break case "session.tool.input.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ - type: "tool", - id: event.data.callID, - name: event.data.name, - time: { created: event.created }, - state: { status: "streaming", input: "" }, - }) + 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": - 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 - }) + 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": - 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 - }) + modifyTool(event.data, (part) => + part.state.status !== "streaming" ? part : { ...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: [] } - }) + 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": - 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] - }) + 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": - 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, - } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.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": - 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, - } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.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": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ - type: "reasoning", - text: "", - state: event.data.state, - time: { created: event.created }, - }) + 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": - 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 - }) + modifyReasoning(event.data, (part) => ({ ...part, text: part.text + event.data.delta })) 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 - } - }) + 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) => { @@ -736,16 +769,20 @@ 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) 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": @@ -914,10 +951,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: { @@ -925,16 +962,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}`) @@ -960,13 +988,41 @@ 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]))) - setStore("session", "message", sessionID, reconcile(messages)) + await syncPending(sessionID) + 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, ...inputIDs()]) + 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(next)) }) }, + 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) { 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..8182bc9e78d7 --- /dev/null +++ b/packages/tui/src/routes/session/content.ts @@ -0,0 +1,132 @@ +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. + * + * 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. + * Design record: quark repo, docs/experiments/quark-message-content.md. + */ +export namespace SessionContent { + type ContentPart = SessionMessageAssistant["content"][number] + export type Part = ContentPart & { readonly partID: string } + export type Parts = ReturnType + /** Read surface for view components; mutation stays with the data layer. */ + 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. + 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; + // 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}` + } + + 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 } + 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}` + + 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) + } + }, + /** + * 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/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 89355246345a..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,10 +63,7 @@ export function DialogMessage(props: { value.type === "user" ? value.text : value.type === "assistant" - ? value.content - .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 a6cf7532fc21..105cafdf34fe 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, @@ -13,6 +14,8 @@ import { Switch, useContext, } from "solid-js" +import { KeyedFor, useSlot, useValue } from "@opencode-ai/quark/solid" +import { SessionContent } from "./content" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -38,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" @@ -72,7 +75,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, type PartRef, type SessionRow } from "./timeline" import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" @@ -205,7 +209,16 @@ export function Session() { }) const editor = useEditorContext() const rows = createSessionRows(() => route.sessionID) - const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) + 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()), + messages(), + ), + ) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) @@ -305,6 +318,7 @@ export function Session() { direction, children: scroll.getChildren(), messages: messages(), + hasText: (messageID) => SessionContent.hasText(partsOf(messageID)), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, currentID: navigationMessage(), @@ -674,7 +688,9 @@ export function Session() { return } - const textParts = lastAssistantMessage.content.filter((part) => part.type === "text") + const textParts = partsOf(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() @@ -712,7 +728,9 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) => + partsOf(messageID).values(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -739,7 +757,9 @@ export function Session() { const content = options.format === "markdown" - ? formatSessionTranscript(sessionData, messages(), options.thinking) + ? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) => + partsOf(messageID).values(), + ) : await (async () => { if (options.debug) { const events: { readonly created: number }[] = [] @@ -922,16 +942,17 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(row, index) => ( data.session.message.get(route.sessionID, messageID)} + parts={partsOf} boundaryID={boundaries()[index()]} /> )} - - + + SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.PartsView boundaryID?: string }) { return ( @@ -1035,10 +1057,17 @@ function SessionRowView(props: { - {(row) => } + {(row) => } - {(row) => } + {(row) => ( + + )} {(row) => ( @@ -1047,6 +1076,7 @@ function SessionRowView(props: { pending={row().pending} completed={row().completed} message={props.message} + parts={props.parts} /> )} @@ -1066,20 +1096,23 @@ function SessionRowView(props: { ) } -function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { +function BackgroundToolHint(props: { + messages: SessionMessageInfo[] + parts: (messageID: string) => SessionContent.PartsView +}) { 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) => { - if (part.type !== "tool" || part.state.status !== "running") return false - const display = toolDisplay(part.name) - return display === "shell" || display === "subagent" - }) ?? false - ) + ), + ) + // 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 false + return useValue(props.parts(message.id).first("backgroundRunning"))() !== undefined }) return ( @@ -1120,13 +1153,23 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { +// 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.PartsView +}) { 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) => ( @@ -1151,20 +1194,22 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) } function SessionReasoningGroupView(props: { - refs: PartRef[] + refs: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined + 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) + const accessors = usePartSlots(props.parts, () => props.refs) 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 }] }), @@ -1189,7 +1234,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() @@ -1251,7 +1299,7 @@ function SessionReasoningGroupView(props: { SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.PartsView }) { const { themeV2 } = useTheme() const ctx = use() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - const parts = (refs: PartRef[]) => - refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) + 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] }) - 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) @@ -1706,122 +1755,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 ( @@ -2360,9 +2293,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)} @@ -2379,9 +2310,13 @@ function BlockTool(props: { {(title) => ( {title()}} + fallback={ + {title()} + } > - {title().replace(/^# /, "")} + + {title().replace(/^# /, "")} + )} @@ -2996,41 +2931,23 @@ 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 } -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..e77c549f3fa0 100644 --- a/packages/tui/src/routes/session/message-navigation.ts +++ b/packages/tui/src/routes/session/message-navigation.ts @@ -19,6 +19,9 @@ export function findMessageBoundary(input: { direction: "next" | "prev" children: readonly MessageChild[] messages: readonly SessionMessageInfo[] + // 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 @@ -35,7 +38,7 @@ 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 [] + 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 3b3ecf41af8e..820e88776fcc 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -15,9 +15,29 @@ 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. + * 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 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()?.() + 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,15 +163,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 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) - if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input - return {} - }) + const input = usePermissionInput(() => props.request) const { themeV2 } = useTheme() @@ -167,7 +179,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 +657,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 +714,14 @@ function Prompt>(props: { setStore("selected", option)} onMouseUp={() => { setStore("selected", option) props.onSelect(option) }} > - + {props.options[option]} @@ -726,7 +731,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 dc4a681bb9d1..c8c697a1c841 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,45 +1,52 @@ -import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { createEffect, on, onCleanup, type Accessor } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" -import { useData } from "../../context/data" +import { Keyed } from "@opencode-ai/quark" +import { useValue } from "@opencode-ai/quark/solid" +import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js" +import { messageIDFromEvent, useData } from "../../context/data" import { useClient } from "../../context/client" - -export type PartRef = { - messageID: string - partID: string -} - -export type SessionRow = - | { type: "message"; messageID: string } - | { type: "compaction-queued"; inputID: string } - | { type: "part"; ref: PartRef } - | { - type: "group" - kind: "reasoning" - refs: PartRef[] - completed: boolean - } - | { - type: "group" - kind: "exploration" - refs: PartRef[] - pending: PartRef[] - completed: boolean - } - | { type: "assistant-footer"; messageID: string } - -export function createSessionRows(sessionID: Accessor) { +import { SessionContent } from "./content" +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 [rows, setRows] = createStore([]) + const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1" + const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined) + 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.replace(value, isPending, pendingPermissions()) + }) + } + 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 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, @@ -47,7 +54,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 +69,17 @@ export function createSessionRows(sessionID: Accessor) { createEffect(() => { const pending = pendingPermissions() - setRows( - produce((draft) => { - partitionPending(draft, pending) - }), - ) + batch(() => state.repartition(pending)) }) createEffect( on([sessionID, () => client.connection.status()], ([id, status]) => { if (status !== "connected") return - setRows(reconcile(reduce())) - void data.session.pending.sync(id).catch(() => undefined) + setRows(reduce()) void data.session.message.sync(id).then( () => { if (sessionID() !== id) return - setRows(reconcile(reduce())) + setRows(reduce()) }, () => undefined, ) @@ -87,7 +89,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 +100,7 @@ export function createSessionRows(sessionID: Accessor) { .list(sessionID()) .filter((item) => item.type === "compaction") .map((item) => item.id), - () => setRows(reconcile(reduce())), + () => setRows(reduce()), ), ) @@ -119,68 +121,35 @@ export function createSessionRows(sessionID: Accessor) { { id: message.id, created: message.time.created, + input: false, + status: message.status, }, ] : [], ), - () => 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 }) - }), - ) - - const appendPart = (ref: PartRef, part: AppendPart) => - setRows( - produce((draft) => { - if (hasPart(draft, ref)) return - append(draft, ref, part, queuedStart(draft)) - }), - ) - - 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 }) - }), - ) - - 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) - }), - ) + batch(() => { + const pending = isPending(messageID) + const message = data.session.message.get(sessionID(), messageID) + state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" }) + }) - 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 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 queuedStart = (rows: SessionRow[]) => { - const index = rows.findIndex( - (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)), - ) - return index === -1 ? rows.length : index - } + const appendFooter = (messageID: string) => batch(() => state.appendFooter(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: { @@ -198,35 +167,41 @@ export function createSessionRows(sessionID: Accessor) { 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" }, ) }), @@ -244,7 +219,7 @@ export function createSessionRows(sessionID: Accessor) { 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) => { @@ -252,128 +227,9 @@ export function createSessionRows(sessionID: Accessor) { }), ] onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) - - 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({ type: "message", messageID: 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({ type: "assistant-footer", messageID: message.id }) - } - return rows - }, []) -} - -export function messageBoundaryIDs(rows: 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.refs[0]?.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) { - if (part.type === "reasoning") { - const previous = rows[index - 1] - if (previous?.type === "group" && previous.kind === "reasoning") { - previous.refs.push(ref) - return - } - completePrevious(rows, index) - rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false }) - return - } - if (part.type === "tool" && exploration(part.name)) { - const previous = rows[index - 1] - if (previous?.type === "group" && previous.kind === "exploration") { - previous.refs.push(ref) - return - } - completePrevious(rows, index) - rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) - return - } - completePrevious(rows, index) - rows.splice(index, 0, { type: "part", ref }) -} - -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)) + onCleanup(() => { + if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`) }) -} -function exploration(name: string) { - return ["read", "glob", "grep"].includes(name.toLowerCase()) -} - -function hasPart(rows: SessionRow[], ref: PartRef) { - return rows.some((row) => { - if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID - if (row.type !== "group") return false - const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs - return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) - }) + return rows } diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts new file mode 100644 index 000000000000..8a47763a7738 --- /dev/null +++ b/packages/tui/src/routes/session/timeline.ts @@ -0,0 +1,325 @@ +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" +import { Keyed, Layout, Transaction } from "@opencode-ai/quark" +import { SessionContent } from "./content" + +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, + /** Cheap no-op check so per-delta callers can skip batching machinery. */ + hasPart: (ref: PartRef) => state.hasMember("parts", partRowID(ref)), + } + } +} + +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]) + 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 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.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 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/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/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index c35138676a9a..4a937a986542 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" @@ -28,6 +29,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 +701,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 +752,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 +874,274 @@ 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("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("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" + 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 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 }) + expect(metrics).toEqual(afterFirst) } finally { app.renderer.destroy() } @@ -957,13 +1293,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 +1497,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 +1514,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 +1571,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 +1585,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 +1617,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 +1638,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 +1702,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 +1724,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", @@ -2313,12 +2666,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" ) }) @@ -2338,19 +2688,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") @@ -2377,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 @@ -2444,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: { @@ -2461,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() @@ -2478,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/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 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..7e70b1a04403 --- /dev/null +++ b/packages/tui/test/cli/tui/permission.test.tsx @@ -0,0 +1,148 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client" +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" +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 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() + 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" + }) + + // 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() + } +}) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c84237a3b768..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,18 @@ import { expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { messageBoundaryIDs, reduceSessionRows } 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 }) => { + 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 +28,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 +58,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 +85,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 +114,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 +142,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 +174,7 @@ test("groups across empty assistant reasoning parts", () => { ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "reasoning", @@ -177,7 +205,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 +237,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 +264,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 +291,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,13 +315,169 @@ 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" }, ]) }) +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", 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 + 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..3f91087ad230 --- /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.some((part) => part.type === "reasoning" && part.text === reasoning)) + throw new Error("the projected reasoning content did not match") + 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}`)) + }), +})