Compare commits

...

18 Commits

Author SHA1 Message Date
Kit Langton 2edc2f5ca2 fix(tui): follow the current permission request in usePermissionInput
The prompt stays mounted while requests queue, so the hook must take a
request accessor; capturing the first request left the input subscribed
to the resolved tool call. Regression test swaps the mounted request.
2026-07-18 22:54:25 -04:00
Kit Langton 3489e7d858 chore(tui): sync lockfile with quark package rename 2026-07-18 22:54:25 -04:00
Kit Langton c0ce124f8c refactor(tui): adopt reactive first index and read-only keyed surface
- quark: Collection.first(name) is now a stable Readable<A | undefined>;
  Keyed.ReadOnly names the read surface; useSlot takes plain constant keys;
  Layout.collectionOf<Target>() replaces the content plan double cast
- BackgroundToolHint reads a backgroundRunning first index instead of
  hand-tracking structure plus tool slots
- toolDisplay moves to util/tool-display (content.ts needs it for the index)
2026-07-18 22:54:25 -04:00
Kit Langton 4027dbb4d8 refactor(tui): simplify quark migration after review
- fix permission input losing reactivity (raw slot read; regression test)
- BackgroundToolHint tracks structure + tool slots instead of whole-collection values
- group views share mapArray-backed usePartSlots (per-ref node reuse)
- dedupe streaming handlers behind variant-scoped modify/insert helpers
- part ID scheme has one definition; hasText required in message navigation
- expose read-only PartsView to view components; misc cleanups
2026-07-18 22:54:24 -04:00
Kit Langton 41a24a9973 test(quark): bind detached subscribe in solid adapter instrumentation
Wrapper methods are shared this-based functions after the standalone sync,
so the test's subscribe interception must bind before detaching.
2026-07-18 22:54:24 -04:00
Kit Langton 9da20b3e9f perf(quark): sync shared wrapper methods from standalone
Vendored sync of quark 15159e5: State and Computed wrappers share
this-based set/update/subscribe methods through a node handle, cutting
three closures per slot. Verified no opencode consumer detaches quark
wrapper methods.
2026-07-18 22:54:24 -04:00
Kit Langton f36acdc6b9 chore(quark): sync tests with standalone 2026-07-18 22:54:23 -04:00
Kit Langton 2a8ad383ea chore(quark): sync reactive kernel and rename package to @opencode-ai/quark 2026-07-18 22:54:23 -04:00
Kit Langton bf10f9d47d chore(quark): insource reactive kernel from standalone
Sync the vendored packages/quark reactivity with the standalone repo's
internal kernel (quark 2a68785) and drop the alien-signals dependency.
Kernel algorithm adapted from alien-signals 3.2.1 with MIT attribution in
packages/quark/THIRD_PARTY_NOTICES.md.
2026-07-18 22:54:22 -04:00
Kit Langton 1dfacc2be9 docs: move experiment records to the quark repo 2026-07-18 22:54:21 -04:00
Kit Langton 6febfccd1e fix(tui): preserve prompts during session hydration
Port of #36433 onto the quark timeline branch: pending operations journal, pending-derived inputs, and hydration that reappends admitted-but-unprojected prompts. Composed with part-collection reseeding.
2026-07-18 22:54:20 -04:00
Kit Langton 95deb9b68a fix(tui): preserve part collection identity across sync 2026-07-18 22:54:20 -04:00
Kit Langton fc310da14d chore(quark): sync vendored keyed and layout with standalone 2026-07-18 22:54:19 -04:00
Kit Langton 088f0df47a chore: fix drive script type narrowing 2026-07-18 22:54:19 -04:00
Kit Langton 6d043d9155 feat(tui): move assistant content into per-part quark slots 2026-07-18 22:54:18 -04:00
Kit Langton 2635451bbf chore(quark): sync vendored quark with diff gating and codegen 2026-07-18 22:54:18 -04:00
Kit Langton aa2d311688 refactor(tui): extract session timeline state 2026-07-18 22:54:18 -04:00
Kit Langton c1222c1095 feat(tui): add quark timeline experiment 2026-07-18 22:54:17 -04:00
35 changed files with 5779 additions and 695 deletions
+10
View File
@@ -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"],
+33
View File
@@ -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.
```
+195
View File
@@ -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<number>()
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()
+60
View File
@@ -0,0 +1,60 @@
export type Workload = {
run(index: number): void
consume(): number
dispose?(): void
}
export type Variant = {
readonly name: string
readonly make: () => Workload
}
export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) {
const samples = options.samples ?? 9
const warmup = options.warmup ?? 500
let checksum = 0
return {
samples,
compare(iterations: number, variants: readonly Variant[]) {
const timings = variants.map(() => [] as number[])
for (let sample = -1; sample < samples; sample++) {
const offset = sample < 0 ? 0 : sample % variants.length
variants
.map((_variant, index) => (index + offset) % variants.length)
.forEach((variantIndex) => {
const workload = variants[variantIndex].make()
for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index)
const start = Bun.nanoseconds()
for (let index = 0; index < iterations; index++) workload.run(index)
const elapsed = Bun.nanoseconds() - start
checksum += workload.consume()
workload.dispose?.()
if (sample >= 0) timings[variantIndex].push(elapsed / iterations)
})
}
const medians = variants.map((variant, index) => {
const median = middle(timings[index])
const mad = middle(timings[index].map((value) => Math.abs(value - median)))
const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")
console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op +/- ${mad.toFixed(1)} MAD`)
console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`)
return median
})
return {
medians,
ratio(left: number, right: number) {
return middle(timings[left].map((value, index) => value / timings[right][index]))
},
}
},
finish() {
console.log(`CHECKSUM ${checksum}`)
},
}
}
function middle(values: number[]) {
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
}
+217
View File
@@ -0,0 +1,217 @@
import { createComputed, createRoot } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Keyed } from "../src"
import { createHarness, type Workload } from "./harness"
type Item = {
readonly id: number
readonly value: number
}
const bench = createHarness()
const results: Array<{ readonly name: string; readonly ratio: number }> = []
function initial(size: number) {
return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 }))
}
function project(values: readonly Item[]) {
return values.reduce((total, value) => total + value.id + value.value, 0)
}
function quarkDirect(size: number, aggregate: boolean): Workload {
const values = initial(size)
const keyed = Keyed.make<Item, number>({
key: (item) => item.id,
equivalent: (left, right) => left.value === right.value,
})
keyed.set(values)
const target = keyed.slots()[Math.floor(size / 2)]
let sink = aggregate ? project(keyed.values()) : target().value
const dispose = aggregate
? keyed.values.subscribe((next) => (sink = project(next)))
: target.subscribe((value) => (sink = value.value))
return {
run: (index) => keyed.update({ id: Math.floor(size / 2), value: index + 1 }),
consume: () => sink,
dispose,
}
}
function solidDirect(size: number, aggregate: boolean): Workload {
let run = (_index: number) => {}
let consume = () => 0
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
const [values, setValues] = createStore(initial(size))
const target = Math.floor(size / 2)
let sink = aggregate ? project(values) : values[target].value
if (aggregate) createComputed(() => (sink = project(values)))
else createComputed(() => (sink = values[target].value))
run = (index) => setValues(target, reconcile({ id: target, value: index + 1 }))
consume = () => sink
})
return { run, consume, dispose }
}
function quarkNoSubscriber(size: number): Workload {
const keyed = Keyed.make<Item, number>({
key: (item) => item.id,
equivalent: (left, right) => left.value === right.value,
})
keyed.set(initial(size))
const target = Math.floor(size / 2)
return {
run: (index) => keyed.update({ id: target, value: index + 1 }),
consume: () => keyed.slots()[target]().value,
}
}
function solidNoSubscriber(size: number): Workload {
const [values, setValues] = createStore(initial(size))
const target = Math.floor(size / 2)
return {
run: (index) => setValues(target, reconcile({ id: target, value: index + 1 })),
consume: () => values[target].value,
}
}
function solidPathWriteNoSubscriber(size: number): Workload {
const [values, setValues] = createStore(initial(size))
const target = Math.floor(size / 2)
return {
run: (index) => setValues(target, "value", index + 1),
consume: () => values[target].value,
}
}
function quarkDense(size: number): Workload {
const keyed = Keyed.make<Item, number>({
key: (item) => item.id,
equivalent: (left, right) => left.value === right.value,
})
keyed.set(initial(size))
let sink = project(keyed.values())
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
return {
run: (index) => keyed.set(initial(size).map((item) => ({ ...item, value: index + 1 }))),
consume: () => sink,
dispose,
}
}
function solidDense(size: number): Workload {
let run = (_index: number) => {}
let consume = () => 0
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
const [values, setValues] = createStore(initial(size))
let sink = project(values)
createComputed(() => (sink = project(values)))
run = (index) => setValues(reconcile(initial(size).map((item) => ({ ...item, value: index + 1 }))))
consume = () => sink
})
return { run, consume, dispose }
}
function quarkUnstable(size: number): Workload {
const keyed = Keyed.make<Item, number>({ key: (item) => item.id })
keyed.set(initial(size))
let sink = project(keyed.values())
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
return {
run(index) {
const offset = (index + 1) * size
keyed.set(initial(size).map((item) => ({ id: item.id + offset, value: index })))
},
consume: () => sink,
dispose,
}
}
function solidUnstable(size: number): Workload {
let run = (_index: number) => {}
let consume = () => 0
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
const [values, setValues] = createStore(initial(size))
let sink = project(values)
createComputed(() => (sink = project(values)))
run = (index) => {
const offset = (index + 1) * size
setValues(reconcile(initial(size).map((item) => ({ id: item.id + offset, value: index }))))
}
consume = () => sink
})
return { run, consume, dispose }
}
function compare(name: string, iterations: number, quark: () => Workload, solid: () => Workload) {
console.log(`\n${name}`)
const result = bench.compare(iterations, [
{ name: `Quark ${name}`, make: quark },
{ name: `Solid ${name}`, make: solid },
])
results.push({ name, ratio: result.ratio(0, 1) })
}
console.log(`Keyed integration benchmark (${bench.samples} samples)`)
compare(
"direct no subscribers 1000",
200_000,
() => quarkNoSubscriber(1_000),
() => solidNoSubscriber(1_000),
)
compare(
"adversarial direct path write 1000",
200_000,
() => quarkNoSubscriber(1_000),
() => solidPathWriteNoSubscriber(1_000),
)
compare(
"subscribed values 10",
100_000,
() => quarkDirect(10, true),
() => solidDirect(10, true),
)
compare(
"subscribed values 100",
25_000,
() => quarkDirect(100, true),
() => solidDirect(100, true),
)
compare(
"subscribed values 1000",
2_500,
() => quarkDirect(1_000, true),
() => solidDirect(1_000, true),
)
compare(
"subscribed values 10000",
250,
() => quarkDirect(10_000, true),
() => solidDirect(10_000, true),
)
compare(
"dense update 1000",
250,
() => quarkDense(1_000),
() => solidDense(1_000),
)
compare(
"unstable keys 100",
1_000,
() => quarkUnstable(100),
() => solidUnstable(100),
)
console.log("\nRatios to Solid (lower is faster)")
results.forEach((result) => {
console.log(`${result.name.padEnd(34)} ${result.ratio.toFixed(3)}x`)
console.log(`METRIC ${result.name.replaceAll(/[^a-z0-9]+/g, "_")}_ratio=${result.ratio.toFixed(6)}`)
})
bench.finish()
+63
View File
@@ -0,0 +1,63 @@
import { createHarness, type Workload } from "./harness"
type Row =
| { readonly type: "message"; readonly messageID: string }
| { readonly type: "part"; readonly messageID: string; readonly partID: string }
| {
readonly type: "group"
readonly kind: "reasoning" | "exploration"
readonly messageID: string
readonly partID: string
}
const size = 10_000
const iterations = 1_000_000
const rows = Array.from({ length: size }, (_, index): Row => {
if (index % 3 === 0) return { type: "message", messageID: `message-${index}` }
if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` }
return {
type: "group",
kind: index % 2 === 0 ? "reasoning" : "exploration",
messageID: `message-${index >> 2}`,
partID: `call-${index}`,
}
})
const precomputed = rows.map((row) => ({ row, id: concatenate(row) }))
const bench = createHarness()
function workload(read: (index: number) => string): Workload {
let sink = 0
return {
run(index) {
sink += read(index % size).length
},
consume: () => sink,
}
}
function json(row: Row) {
if (row.type === "message") return JSON.stringify([row.type, row.messageID])
if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID])
return JSON.stringify([row.type, row.kind, row.messageID, row.partID])
}
function concatenate(row: Row) {
if (row.type === "message") return `m${row.messageID.length}:${row.messageID}`
if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
}
console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`)
const result = bench.compare(iterations, [
{ name: "JSON tuple key", make: () => workload((index) => json(rows[index])) },
{ name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) },
{ name: "Precomputed key", make: () => workload((index) => precomputed[index].id) },
])
console.log("\nRatios to JSON tuple (lower is faster)")
console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`)
console.log(`Precomputed: ${result.ratio(2, 0).toFixed(3)}x`)
console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`)
console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`)
bench.finish()
+19
View File
@@ -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:"
}
}
+3
View File
@@ -0,0 +1,3 @@
export { Keyed } from "./keyed"
export { Layout } from "./layout"
export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
+177
View File
@@ -0,0 +1,177 @@
import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
export namespace Keyed {
export type Position<Key> = "end" | { readonly before: Key } | { readonly after: Key }
export interface Metrics {
slotPublications: number
structuralPublications: number
equivalenceSuppressions: number
}
/** Read surface of a keyed collection; hand this to consumers that must not mutate. */
export interface ReadOnly<A, Key> {
readonly slots: Readable<readonly Readable<A>[]>
readonly values: Readable<readonly A[]>
has(key: Key): boolean
get(key: Key): Readable<A> | undefined
before(key: Key): Readable<A> | undefined
after(key: Key): Readable<A> | undefined
}
export interface Keyed<A, Key> extends ReadOnly<A, Key> {
set(values: readonly A[]): boolean
update(value: A): boolean
modify(key: Key, f: (value: A) => A): boolean
insert(value: A, position?: Position<Key>): Readable<A>
remove(key: Key): boolean
move(key: Key, position?: Position<Key>): boolean
}
export function make<A, Key>(options: {
readonly key: (value: A) => Key
readonly equivalent?: (left: A, right: A) => boolean
readonly metrics?: Metrics
}): Keyed<A, Key> {
const slots = State.make<readonly Writable<A>[]>([])
const byKey = new Map<Key, Writable<A>>()
const equivalent = options.equivalent ?? Object.is
const values = Computed.make<readonly A[]>((previous) => {
const next = slots().map((slot) => slot())
return same(previous, next) ? previous! : next
})
return {
slots,
values,
has: (key) => byKey.has(key),
get: (key) => byKey.get(key),
set(next) {
const keys = next.map(options.key)
const retained = new Set(keys)
if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
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<A>, 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<A>[], position?: Position<Key>) {
if (position === undefined || position === "end") return current.length
if ("before" in position) return indexOf(current, position.before)
return indexOf(current, position.after) + 1
}
function indexOf(current: readonly Writable<A>[], key: Key) {
const target = byKey.get(key)
if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`)
return current.indexOf(target)
}
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<A>(left: readonly A[] | undefined, right: readonly A[]) {
return left?.length === right.length && left.every((value, index) => Object.is(value, right[index]))
}
}
+966
View File
@@ -0,0 +1,966 @@
import { Keyed } from "./keyed"
import { Computed, State, Transaction, type Readable } from "./reactivity"
export namespace Layout {
export interface Field<A> {
readonly isKey?: true
readonly primitive?: true
readonly immutable?: true
equivalent(left: A, right: A): boolean
}
export interface KeyField<A> extends Field<A> {
readonly isKey: true
}
export interface NamedKey<Name extends PropertyKey, A> {
readonly name: Name
readonly field: Field<A>
}
export type Type<Field> = Field extends Layout.Field<infer A> ? A : never
type Fields = Readonly<Record<PropertyKey, Field<unknown>>>
type Value<StructFields> = {
readonly [Key in keyof StructFields]: Type<StructFields[Key]>
}
type KeyName<StructFields> = {
readonly [Key in keyof StructFields]: StructFields[Key] extends KeyField<unknown> ? Key : never
}[keyof StructFields]
type Variant<Tag extends PropertyKey, Variants> = {
readonly [Name in keyof Variants & string]: {
readonly [Key in Tag]: Name
} & Type<Variants[Name]>
}[keyof Variants & string]
type KeyedVariant<Name extends PropertyKey, A, Tag extends PropertyKey, Variants> = {
readonly [Key in Name]: A
} & Variant<Tag, Variants>
export interface Struct<StructFields extends Fields> extends Field<Value<StructFields>> {
readonly type: "struct"
readonly fields: StructFields
}
export interface Union<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>
extends Field<Variant<Tag, Variants>> {
readonly type: "union"
readonly tag: Tag
readonly variants: Variants
}
export interface KeyedUnion<
Name extends PropertyKey,
A,
Tag extends PropertyKey,
Variants extends Readonly<Record<string, Field<unknown>>>,
> extends Field<KeyedVariant<Name, A, Tag, Variants>> {
readonly type: "keyed-union"
readonly key: NamedKey<Name, A>
readonly tag: Tag
readonly variants: Variants
}
export interface Plan<A, Key> {
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<PropertyKey, number>
readonly keyOf: (value: A) => Key
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed<A, Key>
}
type IndexField<A> = A extends unknown ? keyof A : never
export interface MembersIndex<A, Member> {
readonly type: "members"
readonly fields?: readonly IndexField<A>[]
readonly extract: (value: A) => Iterable<Member>
}
export interface FirstIndex<A> {
readonly type: "first"
readonly fields?: readonly IndexField<A>[]
readonly matches: (value: A) => boolean
}
export type Index<A> = MembersIndex<A, unknown> | FirstIndex<A>
type Indexes<A> = Readonly<Record<PropertyKey, Index<A>>>
type MembersNames<Definitions> = {
readonly [Name in keyof Definitions]: Definitions[Name] extends {
readonly type: "members"
}
? Name
: never
}[keyof Definitions]
type FirstNames<Definitions> = {
readonly [Name in keyof Definitions]: Definitions[Name] extends {
readonly type: "first"
}
? Name
: never
}[keyof Definitions]
type Member<Definition> = Definition extends {
readonly extract: (value: never) => Iterable<infer A>
}
? A
: never
type MemberChanges<Definitions> = {
readonly [Name in MembersNames<Definitions>]?: {
readonly add?: readonly Member<Definitions[Name]>[]
readonly remove?: readonly Member<Definitions[Name]>[]
}
}
/**
* 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<A> {
members<Member>(extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
members<Member>(fields: readonly IndexField<A>[], extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
first(matches: (value: A) => boolean): FirstIndex<A>
first(fields: readonly IndexField<A>[], matches: (value: A) => boolean): FirstIndex<A>
}
export interface Collection<A, Key, Definitions extends Indexes<A>> extends Keyed.Keyed<A, Key> {
modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges<Definitions> }): boolean
hasMember<Name extends MembersNames<Definitions>>(name: Name, member: Member<Definitions[Name]>): boolean
/** Reactive first match of the named index; publishes when the first match changes identity or value. */
first<Name extends FirstNames<Definitions>>(name: Name): Readable<A | undefined>
}
export interface CollectionPlan<A, Key, Definitions extends Indexes<A>> extends Plan<A, Key> {
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Collection<A, Key, Definitions>
}
export const string: Field<string> = primitive()
export const number: Field<number> = primitive()
export const boolean: Field<boolean> = primitive()
interface ArrayField<A> extends Field<readonly A[]> {
readonly type: "array"
readonly item: Field<A>
}
export function array<A>(item: Field<A>): Field<readonly A[]> {
const field: ArrayField<A> = {
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<A>(field: Field<A>): Field<A> {
return { ...field, immutable: true, equivalent: () => true }
}
export function key<A>(field: Field<A>): KeyField<A>
export function key<const Name extends PropertyKey, A>(name: Name, field: Field<A>): NamedKey<Name, A>
export function key<A>(name: Field<A> | PropertyKey, field?: Field<A>): KeyField<A> | NamedKey<PropertyKey, A> {
if (field) return { name: name as PropertyKey, field }
return { ...(name as Field<A>), isKey: true }
}
export function struct<const StructFields extends Fields>(fields: StructFields): Struct<StructFields> {
return {
type: "struct",
fields,
equivalent: compileFields<Value<StructFields>>(fields),
}
}
export function union<
const Tag extends PropertyKey,
const Variants extends Readonly<Record<string, Field<unknown>>>,
>(options: { readonly tag: Tag; readonly variants: Variants }): Union<Tag, Variants> {
const equivalent = compileUnion<Tag, Variants>(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<Record<string, Field<unknown>>>,
>(options: {
readonly key: NamedKey<Name, A>
readonly tag: Tag
readonly variants: Variants
}): KeyedUnion<Name, A, Tag, Variants> {
const equivalentVariant = compileUnion<Tag, Variants>(options.tag, options.variants)
const key = (value: KeyedVariant<Name, A, Tag, Variants>) => value[options.key.name]
const equivalent = (left: KeyedVariant<Name, A, Tag, Variants>, right: KeyedVariant<Name, A, Tag, Variants>) =>
options.key.field.equivalent(key(left), key(right)) && equivalentVariant(left, right)
return { type: "keyed-union", ...options, equivalent }
}
export function compile<const StructFields extends Fields>(
layout: Struct<StructFields>,
options?: CompileOptions,
): Plan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>]>
export function compile<
const Name extends PropertyKey,
A,
const Tag extends PropertyKey,
const Variants extends Readonly<Record<string, Field<unknown>>>,
>(layout: KeyedUnion<Name, A, Tag, Variants>, options?: CompileOptions): Plan<KeyedVariant<Name, A, Tag, Variants>, A>
export function compile(input: unknown, options: CompileOptions = {}): unknown {
const layout = input as
| Struct<Fields>
| KeyedUnion<PropertyKey, unknown, PropertyKey, Readonly<Record<string, Field<unknown>>>>
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<PropertyKey, unknown>)[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<PropertyKey, number>()
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<unknown>(fields) : compileEquivalent<unknown>(fields)
const diff = options.backend !== "closure" ? generateDiff<unknown>(fields) : compileDiff<unknown>(fields)
return {
fields: layout.fields,
...makePlan(key, (value: unknown) => (value as Record<PropertyKey, unknown>)[key], equivalent, diff, bits),
}
}
export function collection<const StructFields extends Fields, const Definitions extends Indexes<Value<StructFields>>>(
layout: Struct<StructFields>,
define: (index: IndexBuilder<Value<StructFields>>) => Definitions,
options?: CompileOptions,
): CollectionPlan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>], Definitions>
export function collection<
const Name extends PropertyKey,
A,
const Tag extends PropertyKey,
const Variants extends Readonly<Record<string, Field<unknown>>>,
const Definitions extends Indexes<KeyedVariant<Name, A, Tag, Variants>>,
>(
layout: KeyedUnion<Name, A, Tag, Variants>,
define: (index: IndexBuilder<KeyedVariant<Name, A, Tag, Variants>>) => Definitions,
options?: CompileOptions,
): CollectionPlan<KeyedVariant<Name, A, Tag, Variants>, A, Definitions>
export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown {
const plan = compile(input as never, options) as Plan<unknown, unknown>
const definitions = (define as (index: IndexBuilder<unknown>) => Indexes<unknown>)({
members: ((
fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable<unknown>),
extract?: (value: unknown) => Iterable<unknown>,
) =>
typeof fieldsOrExtract === "function"
? { type: "members", extract: fieldsOrExtract }
: { type: "members", fields: fieldsOrExtract, extract: extract! }) as IndexBuilder<unknown>["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<unknown>["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<Target>() {
return function <
const Name extends keyof Target & PropertyKey,
const Tag extends keyof Target & PropertyKey,
const Variants extends Readonly<Record<Extract<Target[Tag], string>, Field<unknown>>>,
const Definitions extends Indexes<Target>,
>(
layout: KeyedUnion<Name, Target[Name], Tag, Variants>,
define: (index: IndexBuilder<Target>) => Definitions,
options?: CompileOptions,
): CollectionPlan<Target, Target[Name], Definitions> {
return collection(layout as never, define as never, options) as CollectionPlan<Target, Target[Name], Definitions>
}
}
function makePlan<A, Key>(
key: PropertyKey,
getKey: (value: A) => Key,
equivalent: (left: A, right: A) => boolean,
diff: (left: A, right: A) => number,
bits: ReadonlyMap<PropertyKey, number>,
) {
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<A, Key, Definitions extends Indexes<A>>(
plan: Plan<A, Key>,
definitions: Definitions,
initial: readonly A[],
options?: { readonly metrics?: Keyed.Metrics },
): Collection<A, Key, Definitions> {
// `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<A, Key>({
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<number>((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<A>) => 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<A>): void
afterPlacement?(slot: Readable<A>): void
afterRebuild?(): void
member?(candidate: unknown): boolean
readonly first?: Readable<A | undefined>
}
function membersEntry(
name: PropertyKey,
fields: readonly PropertyKey[] | undefined,
extract: (value: A) => Iterable<unknown>,
): Entry {
const counts = new Map<unknown, number>()
const byKey = new Map<Key, { readonly source: Iterable<unknown>; readonly members: Set<unknown> }>()
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<Key>()
// 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<Readable<A> | undefined>(undefined)
const first = Computed.make<A | undefined>(() => slot()?.())
function findFirst() {
slot.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))))
}
function afterPlacement(candidate: Readable<A>) {
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<Record<PropertyKey, MemberChange | undefined>> | 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<A>,
) {
// 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<A, Key, Definitions> = {
...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<Key, A>()
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<Key>) {
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<A>(): Field<A> {
return { primitive: true, equivalent: Object.is }
}
function compileUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
tag: Tag,
variants: Variants,
) {
type A = Variant<Tag, Variants>
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<A>(fields: Fields) {
return compileEquivalent<A>(
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<unknown>; readonly bit: number }
function compileDiff<A>(fields: ReadonlyArray<DiffField>) {
return (left: A, right: A) => {
const a = left as Record<PropertyKey, unknown>
const b = right as Record<PropertyKey, unknown>
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<A>(fields: ReadonlyArray<DiffField>) {
if (fields.some((field) => typeof field.name === "symbol")) return compileDiff<A>(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<Field<unknown>>) => (left: A, right: A) => number
return factory(emitter.custom)
}
type UnionDiffModel = {
readonly bits: ReadonlyMap<PropertyKey, number>
readonly perVariant: ReadonlyMap<string, ReadonlyArray<DiffField> | undefined>
}
function unionDiffModel(
keyName: PropertyKey,
tag: PropertyKey,
variants: Readonly<Record<string, Field<unknown>>>,
): UnionDiffModel {
const bits = new Map<PropertyKey, number>()
bits.set(tag, 1)
bits.set(keyName, 0)
const assigned = new Map<PropertyKey, number>()
const perVariant = new Map<string, ReadonlyArray<DiffField> | undefined>()
let count = 0
for (const variantName of Object.keys(variants)) {
const variant = variants[variantName] as Field<unknown> & { 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<Record<string, Field<unknown>>>,
model: UnionDiffModel,
) {
return (left: unknown, right: unknown) => {
const a = left as Record<PropertyKey, unknown>
const b = right as Record<PropertyKey, unknown>
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<Record<string, Field<unknown>>>,
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<Field<unknown>>) => (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<unknown>[]
readonly declarations: string[]
readonly names: Map<unknown, string>
}
function generateEquivalent<A>(
fields: ReadonlyArray<{
readonly name: PropertyKey
readonly field: Field<unknown>
}>,
) {
// Symbol names cannot appear in generated source; degrade to the closure backend.
if (fields.some((field) => typeof field.name === "symbol")) return compileEquivalent<A>(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<A>(emitter, comparisons.join(" && ") || "true")
}
function generateUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
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<Variant<Tag, Variants>>(emitter, `${root}(left, right)`)
}
function assemble<A>(emitter: Emitter, body: string) {
const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as (
custom: ReadonlyArray<Field<unknown>>,
) => (left: A, right: A) => boolean
return factory(emitter.custom)
}
function expression(emitter: Emitter, field: Field<unknown>, 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<unknown> & {
readonly type?: "struct" | "union" | "keyed-union" | "array"
readonly fields?: Fields
readonly item?: Field<unknown>
readonly tag?: PropertyKey
readonly variants?: Readonly<Record<string, Field<unknown>>>
readonly key?: NamedKey<PropertyKey, unknown>
}
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<Record<string, Field<unknown>>>,
) {
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<A>(
fields: ReadonlyArray<{
readonly name: PropertyKey
readonly field: Field<unknown>
}>,
) {
if (fields.length === 0) return (_left: A, _right: A) => true
return (left: A, right: A) => {
const a = left as Record<PropertyKey, unknown>
const b = right as Record<PropertyKey, unknown>
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) return false
return true
}
}
}
+498
View File
@@ -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> {
(): A
subscribe(listener: (value: A) => void): () => void
}
export interface Writable<A> extends Readable<A> {
set(value: A): void
update(f: (value: A) => A): void
}
export namespace State {
export function make<A>(initial: A): Writable<A> {
const node: StateNode<A> = {
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<A> & Handle<StateNode<A>>
read.node = node
read.set = stateSet
read.update = stateUpdate
read.subscribe = sharedSubscribe
return read
}
}
export namespace Computed {
export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
const node: ComputedNode<A> = {
flags: Flags.None,
value: undefined,
evaluate,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
}
const read = (() => readComputed(node)) as Readable<A> & Handle<ComputedNode<A>>
read.node = node
read.subscribe = sharedSubscribe
return read
}
}
interface Handle<Node> {
node: Node
}
function stateSet<A>(this: Handle<StateNode<A>>, value: A): void {
writeState(this.node, value)
}
function stateUpdate<A>(this: Handle<StateNode<A>>, 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<A>(this: (() => A) & Handle<ReactiveNode>, listener: (value: A) => void): () => void {
return subscribeNode(this.node, this, listener)
}
export namespace Transaction {
export function run<A>(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<A = unknown> extends ReactiveNode {
value: A
pending: A
}
interface ComputedNode<A = unknown> 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<A> {
value: A
prev: Stack<A> | undefined
}
let activeSub: ReactiveNode | undefined
let batchDepth = 0
let version = 0
let flushIndex = 0
let queueLength = 0
const queue: Array<WatcherNode | undefined> = []
const orphans: Array<ReactiveNode> = []
function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
const previous = activeSub
activeSub = sub
return previous
}
function readState<A>(node: StateNode<A>): 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<A>(node: StateNode<A>, 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<A>(node: ComputedNode<A>): 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<A>(node: ComputedNode<A>): 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<A>(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<Link | undefined> | 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<Link> | 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)
}
+46
View File
@@ -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<A>(readable: Readable<A>): Accessor<A> {
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<A, Key>(keyed: Keyed.ReadOnly<A, Key>, key: Key | (() => Key)): Accessor<A | undefined> {
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<A>(props: {
readonly each: Accessor<readonly Readable<A>[]>
readonly fallback?: JSX.Element
readonly children: (value: Accessor<A>, index: Accessor<number>) => JSX.Element
}) {
return For({
get each() {
return props.each()
},
get fallback() {
return props.fallback
},
children: (slot, index) => props.children(useValue(slot), index),
})
}
+339
View File
@@ -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<Item, number>({ 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<Item, number>({ key: (value) => value.id })
keyed.set([item(1, "one"), item(2, "two")])
const initial = keyed.slots()
const structures: Array<readonly number[]> = []
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<Item, number>({ 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<readonly Item[]> = []
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<Item, number>({
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<readonly Item[]> = []
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<number, string>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ key: (value) => value.id })
expect(keyed.slots()).toEqual([])
expect(keyed.values()).toEqual([])
const structures: Array<readonly unknown[]> = []
const aggregates: Array<readonly Item[]> = []
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<Item, number>({ key: (value) => value.id })
keyed.set([item(1, "one"), item(2, "two")])
const structure = keyed.slots()
const two = keyed.slots()[1]
const structures: Array<readonly unknown[]> = []
const dispose = keyed.slots.subscribe((slots) => structures.push(slots))
const updated = item(2, "TWO")
expect(keyed.update(updated)).toBe(true)
expect(keyed.update(updated)).toBe(false)
expect(keyed.slots()).toBe(structure)
expect(keyed.slots()[1]).toBe(two)
expect(two()).toEqual(item(2, "TWO"))
expect(structures).toEqual([])
expect(keyed.update(item(3, "three"))).toBe(false)
dispose()
})
it("modifies one existing slot while preserving its key", () => {
const keyed = Keyed.make<Item, number>({ 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<Item, number>({ key: (value) => value.id })
keyed.set([item(1, "one")])
expect(keyed.has(1)).toBe(true)
expect(keyed.has(2)).toBe(false)
expect(keyed.get(1)).toBe(keyed.slots()[0])
expect(keyed.get(2)).toBeUndefined()
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<Item, number>({ key: (value) => value.id })
keyed.set([item(1, "one"), item(3, "three")])
const one = keyed.slots()[0]
const three = keyed.slots()[1]
const two = keyed.insert(item(2, "two"), { before: 3 })
expect(keyed.slots()).toEqual([one, two, three])
const four = keyed.insert(item(4, "four"), { after: 3 })
expect(keyed.slots()).toEqual([one, two, three, four])
expect(keyed.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<Item, number>({ key: (value) => value.id, metrics })
keyed.set([item(1, "one")])
keyed.update(item(1, "ONE"))
const current = keyed.slots()[0]()
keyed.update(current)
keyed.insert(item(2, "two"))
keyed.move(2, { before: 1 })
keyed.remove(2)
expect(metrics).toEqual({
slotPublications: 1,
structuralPublications: 4,
equivalenceSuppressions: 1,
})
})
})
+561
View File
@@ -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<typeof Root> = {
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<string> = {
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<object>([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<undefined> = { 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",
})
})
})
+427
View File
@@ -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<number> = []
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<number> = []
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<number> = []
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<number> = []
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<number> = []
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<number | undefined> = []
const running = Computed.make<number>((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<number> = []
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<number> = []
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<number> = []
const second: Array<number> = []
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<number> = []
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<number> = []
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<number> = []
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<number> = []
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<number> = []
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<Readable<number>>((current) => {
const next = Computed.make(() => current() + 1)
next()
return next
}, source)
expect(chain()).toBe(depth)
const values: Array<number> = []
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<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
const b: Readable<boolean | null> = 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)
})
})
+100
View File
@@ -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<string | undefined> = []
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<string | undefined> = []
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()
}
}
}
})
})
+120
View File
@@ -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<string>()
const rows = Keyed.make<Group, Group["id"]>({
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<Array<{ type: "group"; refs: PartRef[] }>>([{ 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()
+2
View File
@@ -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",
+241 -185
View File
@@ -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<string, DataSessionStatus>
message: Record<string, SessionMessageInfo[]>
pending: Record<string, SessionPendingInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormWithLocation[]>
@@ -82,6 +79,11 @@ type Store = {
location: Record<string, LocationData>
}
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<string, Map<string, number>>()
// 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, { type: "text" }>) => 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, { type: "reasoning" }>) => 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, { type: "tool" }>) => 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<SessionContent.Part, { type: "tool" }>,
data: { executed: boolean; resultState?: Extract<SessionContent.Part, { type: "tool" }>["providerResultState"] },
completed: number,
) => ({
executed: data.executed || part.executed === true,
providerResultState: data.resultState,
time: { ...part.time, completed },
})
const sync = createSync()
const pendingOperations = new Map<string, PendingOperation[]>()
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<string, number>) => 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}`)
},
+132
View File
@@ -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<typeof PartPlan.make>
/** Read surface for view components; mutation stays with the data layer. */
export type PartsView = Keyed.ReadOnly<Part, string> & Pick<Parts, "first">
// Streamed sub-objects are replaced immutably on change, so reference
// equality is the correct (and cheapest) field comparator for them.
const reference: Layout.Field<unknown> = { 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<Part>()(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.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<string, Parts>()
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<string>) {
const prefix = `${sessionID}\u0000`
for (const key of [...collections.keys()]) {
if (key.startsWith(prefix) && !keep.has(key.slice(prefix.length))) collections.delete(key)
}
},
}
}
}
@@ -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
: ""
+115 -198
View File
@@ -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<string>()
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()}
>
<For each={rows}>
<KeyedFor each={rows.slots}>
{(row, index) => (
<SessionRowView
row={row}
row={row()}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
parts={partsOf}
boundaryID={boundaries()[index()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
</KeyedFor>
<BackgroundToolHint messages={messages()} parts={partsOf} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@@ -1021,6 +1042,7 @@ export function Session() {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.PartsView
boundaryID?: string
}) {
return (
@@ -1035,10 +1057,17 @@ function SessionRowView(props: {
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
{(row) => (
<SessionReasoningGroupView
refs={row().refs}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
@@ -1047,6 +1076,7 @@ function SessionRowView(props: {
pending={row().pending}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
</Match>
@@ -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 (
<Show when={visible() && shortcut()}>
@@ -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 (
<Show when={part()}>
{(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: {
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
fallback={
<For each={props.refs}>
{(ref) => <SessionPartView partRef={ref} message={props.message} parts={props.parts} />}
</For>
}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
@@ -1225,15 +1274,14 @@ function SessionReasoningGroupView(props: {
<box paddingLeft={3}>
<For each={props.refs}>
{(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: {
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
streaming={part()?.time?.completed === undefined && !messageCompleted()}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
@@ -1272,26 +1320,27 @@ function SessionReasoningGroupView(props: {
}
function SessionGroupView(props: {
refs: PartRef[]
pending: PartRef[]
refs: readonly PartRef[]
pending: readonly PartRef[]
completed: boolean
message: (messageID: string) => 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<Record<string, number>>((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<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
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<SessionMessageAssistantTool[][]>(
(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 (
<>
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={themeV2.background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.text.feedback.error()}
>
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
</Switch>
</>
)
}
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) => (
<Show
when={props.spinner}
fallback={<text fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title()}</text>}
fallback={
<text fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title()}</text>
}
>
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title().replace(/^# /, "")}</Spinner>
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
{title().replace(/^# /, "")}
</Spinner>
</Show>
)}
</Show>
@@ -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<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return
return value as Record<string, unknown>
}
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)
@@ -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) }]
})
+29 -23
View File
@@ -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<string, unknown> {
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
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={themeV2.text.subdued()}>This will allow the following patterns until OpenCode is restarted</text>
<text fg={themeV2.text.subdued()}>
This will allow the following patterns until OpenCode is restarted
</text>
<box>
<For each={props.request.save ?? []}>
{(pattern) => (
@@ -643,10 +657,7 @@ function Prompt<const T extends Record<string, string>>(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<const T extends Record<string, string>>(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={themeV2.background.action(
option === store.selected ? "focused" : "default",
)}
backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text
fg={themeV2.text.action(
option === store.selected ? "focused" : "default",
)}
>
<text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
{props.options[option]}
</text>
</box>
@@ -726,7 +731,8 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={themeV2.text()}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
{shortcuts.get("permission.prompt.fullscreen")}{" "}
<span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
</text>
</Show>
<text fg={themeV2.text()}>
+77 -221
View File
@@ -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"
import { SessionContent } from "./content"
import {
SessionTimeline,
compactionQueuedRow,
isTerminalFinish,
reduceSessionRows,
type AppendPart,
type PartRef,
type SessionRow,
} from "./timeline"
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<string>) {
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
const data = useData()
const client = useClient()
const [rows, setRows] = createStore<SessionRow[]>([])
const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1"
const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined)
const state = 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<string>) {
...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
.map((item) => compactionQueuedRow(item.id)),
)
return rows
}
@@ -62,22 +69,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
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<string>) {
// 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<string>) {
.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<string>) {
{
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 })
}),
)
batch(() => {
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" })
})
const appendPart = (ref: PartRef, part: AppendPart) =>
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)
}),
)
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<string>) {
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<string>) {
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<string>) {
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
onCleanup(() => {
if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`)
})
return rows
}
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
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<SessionRow[]>((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<string>()
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<string, SessionMessageInfo>) {
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<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}
function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
function 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)
})
}
+325
View File
@@ -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<typeof PartRefLayout>
export type SessionRow = Layout.Type<typeof SessionRowLayout>
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<string>()
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<string>,
) =>
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<string>) =>
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<string>(),
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<SessionRow[]>((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<string>()
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<string, SessionMessageInfo>) {
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<string>): 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}`
}
+23
View File
@@ -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"
+513 -51
View File
@@ -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<typeof createSessionRows>) {
return rows.values()
}
function withoutRowID(row: SessionRow) {
const { id: _id, ...value } = row
if (value.type === "group") {
const { origin: _origin, ...group } = value
return group
}
return value
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
@@ -687,10 +701,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
let structuralUpdates = 0
const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
function Probe() {
client = useClient()
rows = createSessionRows(() => sessionID)
rows = createSessionRows(() => sessionID, { metrics })
createEffect(() => {
rows.slots()
structuralUpdates++
})
return <box />
}
@@ -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<typeof useData>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_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<typeof useData>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_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<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
rows = createSessionRows(() => sessionID, { metrics })
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_stream_step",
created: 1,
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: "message-assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_stream_started",
created: 2,
type: "session.text.started",
durable: durable(sessionID, 1),
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
})
emitEvent(events, {
id: "evt_stream_delta_1",
created: 3,
type: "session.text.delta",
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
})
await wait(() => readRows(rows).some((row) => row.type === "part"))
const afterFirst = { ...metrics }
emitEvent(events, {
id: "evt_stream_delta_2",
created: 4,
type: "session.text.delta",
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
})
await wait(() => {
const 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<typeof useData>
let rows!: SessionRow[]
let manualRows!: SessionRow[]
let rows!: ReturnType<typeof createSessionRows>
let manualRows!: ReturnType<typeof createSessionRows>
let liveRows!: ReturnType<typeof createSessionRows>
function Probe() {
data = useData()
rows = createSessionRows(() => "session-retry")
manualRows = createSessionRows(() => "session-manual")
liveRows = createSessionRows(() => "session-live")
return <box />
}
@@ -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<void>()
const response = Promise.withResolvers<Response>()
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<typeof useData>
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<void>()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/pending`) return
requested.resolve()
return response.promise
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
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)
@@ -9,8 +9,8 @@ import {
parseDiagnostics,
parseQuestionAnswers,
parseQuestions,
toolDisplay,
} from "../../../src/routes/session"
import { toolDisplay } from "../../../src/util/tool-display"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
@@ -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",
@@ -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 (
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
)
}
function emitEvent(events: ReturnType<typeof createEventStream>, 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<typeof useData>
let client!: ReturnType<typeof useClient>
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 <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<Probe />
</DataProvider>
</ClientProvider>
</TestTuiContexts>
))
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()
}
})
+195 -11
View File
@@ -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<typeof reduceSessionRows>) =>
rows.map(({ id: _id, ...row }) => {
if (row.type !== "group") return row
const { origin: _origin, ...group } = row
return group
})
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
@@ -16,6 +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",
+1
View File
@@ -109,6 +109,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/pending$/.test(url.pathname)) return json({ data: [] })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
+76
View File
@@ -0,0 +1,76 @@
import { Effect, Stream } from "effect"
import { defineScript, Llm } from "opencode-drive"
const marker = "QUARK_GROUP_COMPLETE"
export default defineScript({
project: {
git: true,
files: {
"src/one.ts": "export const one = 1\n",
"src/two.ts": "export const two = 2\n",
},
},
config: {
permissions: [
{ action: "*", resource: "*", effect: "ask" },
{ action: "read", resource: "*one.ts", effect: "allow" },
],
},
tui: { viewport: { cols: 120, rows: 36 }, recording: true },
run: ({ llm, ui }) =>
Effect.gen(function* () {
yield* ui.waitFor((state) => state.focused.editor)
let attempt = 0
yield* llm.serve(() => {
const current = attempt++
if (current === 0)
return Stream.make(
Llm.toolCall({
index: 0,
id: "call_read_one",
name: "read",
input: { path: "src/one.ts" },
}),
Llm.finish("tool-calls"),
)
if (current === 1)
return Stream.make(
Llm.toolCall({
index: 0,
id: "call_read_two",
name: "read",
input: { path: "src/two.ts" },
}),
Llm.finish("tool-calls"),
)
return Stream.make(Llm.text(marker), Llm.finish("stop"))
})
yield* ui.submit("Inspect both source modules, then finish.")
yield* ui.waitFor("Permission required", { timeout: 15_000 })
const frame = yield* ui.capture()
const row = frame.lines.findIndex((line) => line.spans.some((span) => span.text.includes("Exploring")))
if (row === -1) throw new Error("the exploration summary was not visible")
const column = 8
const candidates = (yield* ui.state()).elements.filter(
(element) =>
element.x <= column &&
element.x + element.width > column &&
element.y <= row &&
element.y + element.height > row,
)
if (candidates.length === 0) throw new Error("the exploration summary had no renderable target")
const summary = candidates.reduce((smallest, element) =>
element.width * element.height < smallest.width * smallest.height ? element : smallest,
)
yield* ui.click(summary, { x: column - summary.x, y: row - summary.y })
yield* ui.waitFor("src/one.ts")
const before = yield* ui.screenshot("group-expanded-pending")
yield* ui.enter()
yield* ui.waitFor(marker, { timeout: 15_000 })
yield* ui.waitFor("src/two.ts")
const after = yield* ui.screenshot("group-expanded-complete")
yield* Effect.sync(() => console.log(`ARTIFACT group_before=${before}`))
yield* Effect.sync(() => console.log(`ARTIFACT group_after=${after}`))
}),
})
+46
View File
@@ -0,0 +1,46 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
const marker = "QUARK_TIMELINE_COMPLETE"
const reasoning = Array.from(
{ length: 16 },
(_, index) => `Reasoning segment ${index + 1} checks streamed timeline updates.`,
).join(" ")
const response = `${Array.from(
{ length: 24 },
(_, index) => `Timeline chunk ${index + 1} remains visible and ordered.`,
).join(" ")} ${marker}`
export default defineScript({
project: {
git: true,
files: {
"src/example.ts": "export const timeline = true\n",
},
},
tui: { viewport: { cols: 120, rows: 36 } },
run: ({ opencode, llm, ui }) =>
Effect.gen(function* () {
yield* ui.waitFor((state) => state.focused.editor)
yield* llm.title(() => Effect.succeed("Timeline comparison"))
const started = Date.now()
yield* ui.submit("Explain how this project handles its timeline.")
yield* llm.send(
Llm.reasoning(reasoning, { delay: 1, chunkSize: 8 }),
Llm.text(response, { delay: 1, chunkSize: 8 }),
)
yield* ui.waitFor(marker, { timeout: 30_000 })
const session = (yield* opencode.session.list({})).data[0]
if (!session) throw new Error("the Drive session was not projected")
const assistant = (yield* opencode.message.list({ sessionID: session.id })).data.findLast(
(message) => message.type === "assistant",
)
if (assistant?.type !== "assistant") throw new Error("the assistant message was not projected")
if (!assistant.content.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}`))
}),
})