Compare commits

..
35 changed files with 560 additions and 1273 deletions
-2
View File
@@ -7,7 +7,5 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
export { useWslServers } from "./servers/wsl/context"
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
@@ -49,36 +49,6 @@ migration rules remain explicit in their schemas.
invalid entries individually. Valid entries still pass through their codecs.
- Recovery is not a substitute for an explicit historical shape transformation.
## Writes
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
hides, and the write is skipped when the serialized form did not change. Reactive observers
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
change wins over a value arriving from another window, and over a stored value that finishes
loading after the user already edited. A remote value that arrives while the store is dirty
is held until the save runs; if the local setter calls turned out not to change the
serialized form, the remote value is adopted instead of being lost.
## Namespaces
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
namespace is loaded from the host once, reads are Map lookups from then on, and writes
update the cache immediately while being batched into one host round trip per flush
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
flush on page hide is on the wire before the page goes away; the desktop platform flushes
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
local write carries a sequence number that is kept until the host accepts that exact write;
until then neither the initial load, a change from another window (`accept`), nor the retry
of an older failed batch can replace the key. The host also stamps every update with a
monotonic revision, returned in the ack and carried by change events and loads, so an event
that reaches a window after a newer ack or load for the same key is recognised as stale and
dropped; an event held back during an in-flight write is applied after the ack when the host
ordered it later.
## Migrations
Describe shipped representations with schemas and transform their typed values
@@ -1,247 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
// that the test delivers to other windows whenever it chooses, like the real event stream.
function host(initial: Record<string, Record<string, string>> = {}) {
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
const calls: Call[] = []
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
let revision = 0
let fail: (insert: Record<string, string>) => boolean = () => false
let gate: Promise<void> | undefined
const driver: NamespaceDriver = {
items: async (name) => {
calls.push({ kind: "items", name })
return { items: Object.fromEntries(data.get(name) ?? []), revision }
},
update: async (name, insert, remove) => {
calls.push({ kind: "update", name, insert, remove })
await gate
if (fail(insert)) throw new Error("disk full")
const items = data.get(name) ?? new Map()
for (const [key, value] of Object.entries(insert)) items.set(key, value)
for (const key of remove) items.delete(key)
data.set(name, items)
events.push({ name, insert, remove, revision: ++revision })
return revision
},
clear: async (name) => {
calls.push({ kind: "clear", name })
data.delete(name)
},
}
return {
driver,
data,
calls,
events,
updates: () => calls.filter((call) => call.kind === "update"),
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
(fail = typeof value === "boolean" ? () => value : value),
setGate: (value: Promise<void> | undefined) => (gate = value),
deliver: (target: NamespaceStorage, index: number) => {
const event = events[index]!
target.accept(event.insert, event.remove, event.revision)
},
}
}
describe("namespace storage", () => {
test("loads the namespace once and serves reads from memory", async () => {
const h = host({ w: { tabs: "[]", recent: "{}" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
expect(await storage.getItem("tabs")).toBe("[]")
expect(await storage.getItem("recent")).toBe("{}")
expect(await storage.getItem("missing")).toBeNull()
expect(await storage.getLength()).toBe(2)
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
})
test("reads its own writes immediately and coalesces them into one update", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
void storage.setItem("tabs", "[1,2]")
void storage.removeItem("recent")
expect(await storage.getItem("tabs")).toBe("[1,2]")
expect(await storage.getItem("recent")).toBeNull()
expect(h.updates()).toHaveLength(0)
await wait(30)
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
})
test("writes made while loading win over the loaded snapshot", async () => {
const h = host({ w: { tabs: "old" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
const read = storage.getItem("tabs")
void storage.setItem("tabs", "new")
expect(await read).toBe("new")
})
test("flush writes now and resolves after the driver accepted the batch", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
await storage.flush()
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
await storage.flush()
expect(h.updates()).toHaveLength(1)
})
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
const h = host()
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
h.setFail(true)
void storage.setItem("tabs", "[1]")
void storage.setItem("recent", "{}")
await storage.flush()
expect(h.data.get("w")).toBeUndefined()
h.setFail(false)
void storage.setItem("tabs", "[2]")
await storage.flush()
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
})
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "[1]")
void storage.flush()
void storage.setItem("tabs", "[2]")
void storage.flush()
// Both batches reached the driver while the first reply is still outstanding.
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
first.resolve()
await storage.flush()
})
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const accepted = Promise.withResolvers<number>()
const driver: NamespaceDriver = {
items: () => loaded.promise,
update: () => accepted.promise,
clear: async () => undefined,
}
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
void storage.setItem("model", "local")
void storage.flush()
storage.accept({ model: "other-window-older" }, [], 1)
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
expect(await read).toBe("local")
accepted.resolve(2)
await storage.flush()
expect(await storage.getItem("model")).toBe("local")
storage.accept({ model: "other-window-newer" }, [], 3)
expect(await storage.getItem("model")).toBe("other-window-newer")
})
test("a failed batch does not requeue a value a later batch already replaced", async () => {
const h = host()
const first = Promise.withResolvers<void>()
h.setGate(first.promise)
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
h.setFail((insert) => insert.tabs === "old")
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
void storage.setItem("tabs", "old")
void storage.flush()
h.setGate(undefined)
void storage.setItem("tabs", "new")
void storage.flush()
await wait(0)
expect(h.data.get("w")?.get("tabs")).toBe("new")
first.resolve()
await storage.flush()
await storage.flush()
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
expect(h.data.get("w")?.get("tabs")).toBe("new")
expect(await storage.getItem("tabs")).toBe("new")
})
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
const h = host({ g: { model: "start" } })
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await one.getItem("model")
await two.getItem("model")
void one.setItem("model", "A")
await one.flush()
void two.setItem("model", "B")
await two.flush()
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
h.deliver(two, 0)
expect(await two.getItem("model")).toBe("B")
expect(h.data.get("g")?.get("model")).toBe("B")
// One still receives B, which is newer than its own ack.
h.deliver(one, 1)
expect(await one.getItem("model")).toBe("B")
})
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
const h = host()
const gate = Promise.withResolvers<void>()
h.setGate(gate.promise)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
void storage.setItem("model", "mine")
void storage.flush()
// Another window's write for the same key landed at the host after ours will.
storage.accept({ model: "theirs" }, [], 2)
expect(await storage.getItem("model")).toBe("mine")
gate.resolve()
await storage.flush()
expect(await storage.getItem("model")).toBe("theirs")
})
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
const read = storage.getItem("model")
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
storage.accept({ model: "inserted" }, [], 41)
loaded.resolve({ items: {}, revision: 42 })
expect(await read).toBeNull()
// The delete event is older than the floor and must stay a no-op either way.
storage.accept({}, ["model"], 42)
expect(await storage.getItem("model")).toBeNull()
// A key inserted by an event newer than the snapshot survives the load.
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
const pending = other.getItem("model")
other.accept({ model: "after-snapshot" }, [], 43)
second.resolve({ items: {}, revision: 42 })
expect(await pending).toBe("after-snapshot")
})
test("an event older than the initial load is ignored", async () => {
const h = host({ g: { model: "loaded" } })
void h.driver.update("g", { model: "loaded" }, [])
await wait(0)
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
await storage.getItem("model")
storage.accept({ model: "before-load" }, [], 1)
expect(await storage.getItem("model")).toBe("loaded")
storage.accept({}, ["model"], 2)
expect(await storage.getItem("model")).toBeNull()
})
test("clear drops the cache and queued changes and clears the driver", async () => {
const h = host({ w: { tabs: "[]" } })
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
await storage.getItem("tabs")
void storage.setItem("recent", "{}")
await storage.clear()
expect(await storage.getItem("tabs")).toBeNull()
expect(await storage.getItem("recent")).toBeNull()
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
})
})
@@ -1,159 +0,0 @@
import type { AsyncStorage } from "@solid-primitives/storage"
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
// update with a monotonic revision and reports it with reads, acks, and change events.
export type NamespaceDriver = {
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
clear(name: string): Promise<void>
}
export type NamespaceStorage = AsyncStorage & {
/** Write every queued change now. Resolves when the driver has accepted it. */
flush(): Promise<void>
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
accept(insert: Record<string, string>, remove: string[], revision: number): void
}
export const namespaceFlushDelay = 100
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
// on; writes update the cache immediately and are batched into one driver call per flush window,
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
//
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
// recorded until the host acks that exact write; while recorded, nothing external may replace
// the key. Every value the cache holds also carries the host revision it came from, so an event
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
// on the wire before the page goes away.
export function createNamespaceStorage(
driver: NamespaceDriver,
name: string,
options: { delay?: number } = {},
): NamespaceStorage {
const delay = options.delay ?? namespaceFlushDelay
const cache = new Map<string, string>()
const local = new Map<string, { seq: number; value: string | null }>()
const dirty = new Set<string>()
const inflight = new Set<Promise<void>>()
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
const applied = new Map<string, number>()
let floor = -1
// The newest external change for a key that arrived while a local write was still in flight.
const deferred = new Map<string, { revision: number; value: string | null }>()
let seq = 0
let loading: Promise<void> | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const place = (key: string, value: string | null, revision: number) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
applied.set(key, revision)
}
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
// an older event inserted it into the cache while the load was in flight.
const load = () =>
(loading ??= driver.items(name).then((loaded) => {
floor = loaded.revision
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
for (const key of [...cache.keys()]) {
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
}
for (const [key, value] of Object.entries(loaded.items)) {
if (stale(key)) place(key, value, loaded.revision)
}
}))
const write = (key: string, value: string | null) => {
if (value === null) cache.delete(key)
else cache.set(key, value)
local.set(key, { seq: ++seq, value })
dirty.add(key)
timer ??= setTimeout(() => void flush(), delay)
}
// The host accepted this window's value for `key` at `revision`. A change from another window
// that was held back meanwhile wins if the host applied it later than ours.
const acknowledge = (key: string, revision: number) => {
local.delete(key)
const later = deferred.get(key)
deferred.delete(key)
if (later && later.revision > revision) return place(key, later.value, later.revision)
applied.set(key, revision)
}
const flush = () => {
clearTimeout(timer)
timer = undefined
if (dirty.size > 0) {
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
dirty.clear()
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
const request = driver
.update(name, insert, remove)
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
.catch((error: unknown) => {
// Only a value nothing newer has replaced is worth retrying.
batch.filter(current).forEach((entry) => dirty.add(entry.key))
console.error(`[persistence] flush failed for ${name}`, error)
})
.finally(() => inflight.delete(request))
inflight.add(request)
}
return Promise.all(inflight).then(() => undefined)
}
const storage: NamespaceStorage = {
getItem: async (key) => {
await load()
return cache.get(key) ?? null
},
setItem: async (key, value) => write(key, value),
removeItem: async (key) => write(key, null),
clear: async () => {
clearTimeout(timer)
timer = undefined
cache.clear()
local.clear()
dirty.clear()
applied.clear()
deferred.clear()
loading = Promise.resolve()
await driver.clear(name)
},
key: async (index: number) => {
await load()
return [...cache.keys()][index]
},
getLength: async () => {
await load()
return cache.size
},
get length() {
return storage.getLength()
},
flush,
accept(insert, remove, revision) {
// The initial load already reflects everything up to `floor`.
if (revision <= floor) return
const changes = [
...Object.entries(insert).map(([key, value]) => [key, value] as const),
...remove.map((key) => [key, null] as const),
]
for (const [key, value] of changes) {
if (local.has(key)) {
const held = deferred.get(key)
if (!held || revision > held.revision) deferred.set(key, { revision, value })
continue
}
if (revision <= (applied.get(key) ?? floor)) continue
place(key, value, revision)
}
},
}
return storage
}
@@ -1,156 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { flushPersisted, persistStore } from "./persist"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type State = { count: number; label: string }
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
const writes: string[] = []
return createRoot((dispose) => {
const [store, setStore] = createStore<State>({ count: 0, label: "" })
const persist = persistStore({
store,
setStore,
name: "state",
// One fixture serves both the sync and the async storage shape.
storage: {
getItem: () => input.initial ?? null,
setItem: (_key: string, value: string) => {
writes.push(value)
},
removeItem: () => {},
} as SyncStorage | AsyncStorage,
serialize: JSON.stringify,
deserialize: JSON.parse,
sync: input.sync,
delay: input.delay ?? 10,
})
return { store, set: persist.setStore, persist, writes, dispose }
})
}
describe("persistStore", () => {
test("marks the store dirty on set and writes once after the delay", async () => {
const value = setup({})
value.set("count", 1)
value.set("count", 2)
value.set("label", "a")
expect(value.store.count).toBe(2)
expect(value.writes).toEqual([])
await wait(30)
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
value.dispose()
})
test("skips the write when the serialized value did not change", () => {
const value = setup({ delay: 10_000 })
value.set("count", 1)
value.persist.flush()
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("hydrates synchronously from sync storage without writing back", () => {
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
expect(value.store).toEqual({ count: 5, label: "saved" })
value.persist.flush()
expect(value.writes).toEqual([])
value.dispose()
})
test("a set made while async storage loads wins over the loaded value", async () => {
const loading = Promise.withResolvers<string | null>()
const value = setup({ initial: loading.promise, delay: 10_000 })
value.set("count", 9)
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
await loading.promise
expect(value.store.count).toBe(9)
value.dispose()
})
test("applies another window's value when clean and ignores it while dirty", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "remote" })
value.set("label", "local")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
expect(value.store).toEqual({ count: 3, label: "local" })
value.persist.flush()
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
value.dispose()
})
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
const listeners: PersistenceSyncCallback[] = []
const sent: string[] = []
const value = setup({
delay: 10_000,
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
})
value.set("count", 1)
value.persist.flush()
// Setting the same value again marks the store dirty without changing it.
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
expect(value.store.label).toBe("")
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "remote" })
expect(value.writes).toHaveLength(1)
expect(sent).toHaveLength(1)
// A later save must not consider the adopted value a local change.
value.set("count", 1)
value.persist.flush()
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("label", "saved")
value.persist.flush()
value.set("label", "saved")
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 0, label: "saved" })
expect(value.writes).toHaveLength(1)
value.dispose()
})
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
const listeners: PersistenceSyncCallback[] = []
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
value.set("count", 1)
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
value.persist.flush()
expect(value.store).toEqual({ count: 1, label: "" })
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
value.dispose()
})
test("disposing the owner and flushPersisted both save pending changes", () => {
const first = setup({ delay: 10_000 })
first.set("count", 1)
first.dispose()
expect(first.writes).toHaveLength(1)
const second = setup({ delay: 10_000 })
second.set("count", 2)
flushPersisted()
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
second.dispose()
expect(second.writes).toHaveLength(1)
})
})
@@ -1,100 +0,0 @@
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
import { getOwner, onCleanup, untrack } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
export const persistSaveDelay = 100
const pending = new Set<() => void>()
/** Serialize and write every store with unsaved changes now. */
export function flushPersisted() {
for (const save of [...pending]) save()
}
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushPersisted()
})
window.addEventListener("pagehide", flushPersisted)
}
// A store whose serialized form is written to storage on a schedule instead of on every setter
// call. The setter only marks the store dirty; serialization happens once per save window, once
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
export function persistStore<T extends object>(input: {
store: Store<T>
setStore: SetStoreFunction<T>
name: string
storage: SyncStorage | AsyncStorage
serialize: (value: T) => string
deserialize: (raw: string) => T
sync?: PersistenceSyncAPI
delay?: number
}) {
const delay = input.delay ?? persistSaveDelay
let dirty = false
let touched = false
let last: string | undefined
// The newest value another window wrote while this store was dirty; applied at save time if the
// local setter calls turned out not to change anything.
let remote: string | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const save = () => {
clearTimeout(timer)
timer = undefined
pending.delete(save)
if (!dirty) return
dirty = false
const held = remote
remote = undefined
const next = untrack(() => input.serialize(input.store))
if (next === last) {
if (held !== undefined && held !== last) hydrate(held)
return
}
last = next
input.sync?.[1](input.name, next)
void input.storage.setItem(input.name, next)
}
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
const apply = input.setStore as unknown as (...values: unknown[]) => void
const setStore = ((...values: unknown[]) => {
apply(...values)
dirty = true
touched = true
pending.add(save)
timer ??= setTimeout(save, delay)
}) as unknown as SetStoreFunction<T>
const hydrate = (raw: string) => {
last = raw
input.setStore(reconcile(input.deserialize(raw)))
}
const init = input.storage.getItem(input.name)
// A value the user already changed is newer than whatever storage held.
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
else if (init) hydrate(init)
input.sync?.[0]((data) => {
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
if (!data.newValue) return
// A real unsaved local change wins over another window's write, as in VS Code's storage
// service; whether the change is real is only known when the store is serialized. Every
// remote value replaces the held one, including a revert to `last`, so the save sees the
// other window's final state rather than an intermediate one.
if (dirty) {
remote = data.newValue
return
}
if (data.newValue === last) return
hydrate(data.newValue)
})
if (getOwner()) onCleanup(save)
return { setStore, init, flush: save }
}
@@ -1,15 +1,14 @@
import { Platform, usePlatform } from "@/runtime/platform/platform"
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, onCleanup, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Option, Schema } from "effect"
import { pathKey } from "@/workspaces/path-key"
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
import { persistStore } from "./persist"
import { Persistence } from "./schema"
type InitType = Promise<string | null> | string | null
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
Store<T>,
SetStoreFunction<T>,
@@ -594,18 +593,13 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
: undefined
if (channel) onCleanup(() => channel.close())
const persist = persistStore({
store: store[0],
setStore: store[1],
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
name: config.key,
storage,
serialize,
deserialize: Schema.decodeUnknownSync(json),
sync: channel ? messageSync(channel) : undefined,
})
const state = store[0]
const setState = persist.setStore
const init = persist.init
const isAsync = init instanceof Promise
const [ready] = createResource(
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose)
activationToggles.set(key, setInstanceQueriesEnabled)
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
void init.then(() => {
if (children[key] !== child) return
@@ -4,7 +4,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { ServerScope } from "@/runtime/server/scope"
import { createComposerState, type ComposerStore } from "@/composer/state"
import { createComposerEditorActions } from "@/composer/editor/actions"
import { flushPersisted } from "@/runtime/persistence/persist"
function setup(read: () => string | null | Promise<string | null> = () => null) {
return createRoot((dispose) => {
@@ -28,72 +27,83 @@ function setup(read: () => string | null | Promise<string | null> = () => null)
})
}
test("composer-write-batch: a burst of edits persists once with prompt, cursor and retry together", async () => {
test("composer-write-batch: typing persists prompt, cursor and retry together before returning", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.context.add({ type: "file", path: "src/queue.ts", preview: "await queue.flush()" })
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
flushPersisted()
value.writes.length = 0
value.editor.setPrompt([{ type: "text", content: "keep ordering", start: 0, end: 13 }], 13)
value.editor.setCursor(12)
expect(value.writes).toHaveLength(0)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({
prompt: [{ type: "text", content: "keep ordering", start: 0, end: 13 }],
cursor: 12,
cursor: 13,
context: { items: [{ path: "src/queue.ts", preview: "await queue.flush()" }] },
})
expect(value.writes[0].retry).toBeUndefined()
} finally {
value.dispose()
}
})
test("composer-write-batch: a save with no serialized change writes nothing", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
value.state.mode.set("shell")
flushPersisted()
value.writes.length = 0
value.editor.setCursor(5)
value.state.mode.set("shell")
flushPersisted()
expect(value.writes).toHaveLength(0)
value.state.reset()
flushPersisted()
value.editor.setCursor(13)
expect(value.writes).toHaveLength(1)
expect(value.writes[0]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.editor.setCursor(12)
expect(value.writes.map((write) => write.cursor)).toEqual([13, 12])
value.editor.setCursor(12)
expect(value.writes).toHaveLength(2)
} finally {
value.dispose()
}
})
test("composer-write-batch: state replacement snapshots the value at save time", async () => {
test("composer-write-batch: state replacement preserves an omitted cursor and resets in one write", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
flushPersisted()
value.state.mode.set("shell")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
const prompt = [{ type: "text" as const, content: "next", start: 0, end: 4 }]
value.state.set(prompt)
prompt[0].content = "changed outside the store"
expect(value.state.current()[0]).toMatchObject({ content: "next" })
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "next", start: 0, end: 4 }])
expect(value.writes[0].cursor).toBe(5)
expect(value.writes[0].retry).toBeUndefined()
value.state.reset()
expect(value.writes).toHaveLength(2)
expect(value.writes[1]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
value.state.mode.set("normal")
value.state.mode.set("normal")
expect(value.writes).toHaveLength(3)
expect(value.writes[2].mode).toBe("normal")
} finally {
value.dispose()
}
})
test("composer-write-batch: the last edit in a window wins and attachments are retained", async () => {
test("composer-write-batch: unchanged mode still clears a retry and context writes remain ordered", async () => {
const value = setup()
try {
await value.state.ready.promise
value.state.mode.set("normal")
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
value.writes.length = 0
value.editor.setMode("normal")
expect(value.writes).toHaveLength(1)
expect(value.writes[0].retry).toBeUndefined()
value.state.context.add({ type: "file", path: "first.ts" })
value.state.context.add({ type: "file", path: "second.ts" })
value.state.context.remove(value.state.context.items()[0].key)
expect(value.writes.slice(1).map((write) => write.context.items.map((item) => item.path))).toEqual([
["first.ts"],
["first.ts", "second.ts"],
["second.ts"],
])
} finally {
value.dispose()
}
})
test("composer-write-batch: text replacement and insertion each persist once and retain attachments", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -110,14 +120,12 @@ test("composer-write-batch: the last edit in a window wins and attachments are r
],
3,
)
flushPersisted()
value.writes.length = 0
value.editor.setText("new")
value.editor.addText(" notes")
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].cursor).toBe(9)
expect(value.writes[0].prompt).toEqual([
expect(value.writes).toHaveLength(2)
expect(value.writes.map((write) => write.cursor)).toEqual([3, 9])
expect(value.writes[1].prompt).toEqual([
{ type: "text", content: "new notes", start: 0, end: 9 },
{
type: "image",
@@ -137,6 +145,7 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
const value = setup(() => loading.promise)
try {
value.editor.setPrompt([{ type: "text", content: "new", start: 0, end: 3 }], 3)
expect(value.writes).toHaveLength(1)
loading.resolve(
JSON.stringify({
prompt: [{ type: "text", content: "old", start: 0, end: 3 }],
@@ -147,15 +156,13 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
await value.state.ready.promise
expect(value.state.current()).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
expect(value.state.cursor()).toBe(3)
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
} finally {
value.dispose()
}
})
test("composer-write-batch: observers see every edit before anything is persisted", async () => {
test("composer-write-batch: persistence still precedes reactive observers", async () => {
const value = setup()
try {
await value.state.ready.promise
@@ -170,22 +177,10 @@ test("composer-write-batch: observers see every edit before anything is persiste
value.editor.addText(" and insert")
value.state.set([{ type: "text", content: "restore", start: 0, end: 7 }], 7)
value.state.reset()
expect(observed).toEqual([0, 0, 0, 0, 0, 0])
flushPersisted()
expect(value.writes).toHaveLength(1)
expect(observed).toEqual([0, 1, 2, 3, 4, 5])
dispose()
})
} finally {
value.dispose()
}
})
test("composer-write-batch: disposing the owner saves pending edits", async () => {
const value = setup()
await value.state.ready.promise
value.editor.setPrompt([{ type: "text", content: "unsaved", start: 0, end: 7 }], 7)
expect(value.writes).toHaveLength(0)
value.dispose()
expect(value.writes).toHaveLength(1)
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "unsaved", start: 0, end: 7 }])
})
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { flushPersisted } from "@/runtime/persistence/persist"
import { persisted } from "@/runtime/persistence/storage"
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
@@ -26,7 +25,6 @@ test("persisted model selection hydrates, updates and serializes the schema shap
expect(state.session.session1?.agent).toBe("plan")
setState("session", "session1", { agent: "build", variant: null })
expect(state.session.session1?.agent).toBe("build")
flushPersisted()
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
session: { session1: { agent: "build", variant: null } },
})
@@ -6,7 +6,6 @@ import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
let read: ((value: string | null) => void) | undefined
@@ -110,7 +109,6 @@ describe("prompt persistence", () => {
},
])
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
expect(documents.get(key)).toContain("hello")
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { Schema, SchemaGetter } from "effect"
import { createComputed, createRoot } from "solid-js"
import type { Platform } from "@/runtime/platform/platform"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
@@ -58,7 +57,6 @@ describe("schema-backed persistence", () => {
expect(state.key).toBe("session-tab")
setState("key", undefined)
expect(state.key).toBeUndefined()
flushPersisted()
expect(localStorage.getItem(key)).toBe("{}")
} finally {
dispose()
@@ -76,7 +74,6 @@ describe("schema-backed persistence", () => {
expect(state).toEqual({ enabled: true, label: "saved" })
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
setState("enabled", false)
flushPersisted()
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
dispose()
})
@@ -128,7 +125,6 @@ describe("schema-backed persistence", () => {
label: "desktop",
})
root.state[1]("label", "changed")
flushPersisted()
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
enabled: true,
label: "changed",
+11 -8
View File
@@ -92,15 +92,18 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
output: result,
content: toModelContent(result),
metadata: {
files: [fileDiff(result.resource, current?.text ?? "", formatted, current ? "modified" : "added")],
},
}
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
}),
)
.pipe(Effect.orDie)
+63 -1
View File
@@ -119,6 +119,17 @@ describe("WriteTool", () => {
existed: false,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: {
files: [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
],
},
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -158,6 +169,20 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
metadata: {
files: [
{
file: "formatted.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+FORMAT ME"),
},
],
},
})
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
@@ -180,6 +205,7 @@ describe("WriteTool", () => {
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.metadata).toEqual(fixture.assertions[0]?.metadata)
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [
{
@@ -215,7 +241,22 @@ describe("WriteTool", () => {
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
const settled = yield* executeTool(
registry,
call({ path: "preserved.txt", content: "after" }, "call-preserved"),
)
expect(settled).toMatchObject({
metadata: {
files: [
{
file: "preserved.txt",
status: "modified",
patch: expect.stringMatching(/-before[\s\S]*\+after/),
},
],
},
})
if (settled.status === "completed") expect(JSON.stringify(settled.metadata)).not.toContain("\uFEFF")
yield* executeTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
@@ -230,6 +271,27 @@ describe("WriteTool", () => {
}),
)
it.live("reports zero-change metadata for empty and unchanged writes", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
const created = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-empty"))
expect(created).toMatchObject({
status: "completed",
metadata: { files: [{ file: "empty.txt", status: "added", additions: 0, deletions: 0 }] },
})
const unchanged = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-unchanged"))
expect(unchanged).toMatchObject({
status: "completed",
metadata: { files: [{ file: "empty.txt", status: "modified", additions: 0, deletions: 0 }] },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "empty.txt"), "utf8"))).toBe("")
}),
)
}),
)
it.live("accepts an absolute file path inside the active Location", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
@@ -1,30 +1,17 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import { StorageChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
const handoff = yield* IpcPortHandoff
return StorageRpcs.of({
StorageItems: ({ name }) => Effect.sync(() => storage.state.items(name)),
StorageUpdate: ({ name, insert, remove }, context) =>
Effect.sync(() => {
const revision = storage.state.update(name, insert, remove)
// Other windows hold their own copy of this namespace; tell them what moved.
const origin = sender(handoff, context)
const event = new StorageChanged({ name, insert, remove, revision })
for (const win of BrowserWindow.getAllWindows()) {
if (win.webContents !== origin) emitIpcEvent(win.webContents, event)
}
return revision
}),
StorageGet: ({ name, key }) => Effect.sync(() => storage.state.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.state.set(name, key, value)),
StorageDelete: ({ name, key }) => Effect.sync(() => storage.state.delete(name, key)),
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
StorageKeys: ({ name }) => Effect.sync(() => storage.state.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.state.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
+10 -13
View File
@@ -37,24 +37,21 @@ describe("state store", () => {
store.flush()
store.delete("global", "model")
expect(store.get("global", "model")).toBeNull()
expect(store.items("global").items).toEqual({})
expect(store.keys("global")).toEqual([])
store.flush()
expect(rows(db)).toEqual([])
})
test("items merges stored rows with queued changes and update returns a rising revision", () => {
const { db, store } = open()
expect(store.items("w")).toEqual({ items: {}, revision: 0 })
expect(store.update("w", { tabs: "[]", recent: "{}" }, [])).toBe(1)
test("keys and length merge stored rows with queued changes", () => {
const { store } = open()
store.set("w", "tabs", "[]")
store.set("w", "recent", "{}")
store.flush()
expect(store.update("w", { info: "{}" }, ["recent"])).toBe(2)
expect(store.items("w")).toEqual({ items: { tabs: "[]", info: "{}" }, revision: 2 })
expect(store.items("other")).toEqual({ items: {}, revision: 2 })
store.flush()
expect(rows(db)).toEqual([
{ name: "w", key: "info", value: "{}" },
{ name: "w", key: "tabs", value: "[]" },
])
store.set("w", "info", "{}")
store.delete("w", "recent")
expect(store.keys("w").sort()).toEqual(["info", "tabs"])
expect(store.length("w")).toBe(2)
expect(store.keys("other")).toEqual([])
})
test("clear drops a namespace including queued writes and leaves others alone", () => {
+20 -31
View File
@@ -40,12 +40,22 @@ export function createStateStore(db: Database, input: { delay?: number; onError?
}),
})
const id = (name: string, key: string) => `${name}\0${key}`
const set = (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value })
const unset = (name: string, key: string) => writer.set(id(name, key), { name, key, value: null })
// Orders updates for renderer caches: acks and change events reach a window on different
// paths, so a window compares revisions rather than arrival order. Process-local is enough
// because every renderer cache dies with the process too.
let revision = 0
const keys = (name: string) => {
const result = new Set(
db
.select({ key: state.key })
.from(state)
.where(eq(state.name, name))
.all()
.map((row) => row.key),
)
for (const row of writer.entries()) {
if (row.name !== name) continue
if (row.value === null) result.delete(row.key)
else result.add(row.key)
}
return [...result]
}
return {
get(name: string, key: string) {
@@ -53,31 +63,10 @@ export function createStateStore(db: Database, input: { delay?: number; onError?
if (queued) return queued.value
return read.get({ name, key })?.value ?? null
},
set,
delete: unset,
// A renderer loads a namespace once, so queued rows must be folded in for it to see its own
// writes from a previous window session that have not flushed yet.
items(name: string) {
const items = Object.fromEntries(
db
.select({ key: state.key, value: state.value })
.from(state)
.where(eq(state.name, name))
.all()
.map((row) => [row.key, row.value]),
)
for (const row of writer.entries()) {
if (row.name !== name) continue
if (row.value === null) delete items[row.key]
else items[row.key] = row.value
}
return { items, revision }
},
update(name: string, insert: Record<string, string>, removed: readonly string[]) {
for (const [key, value] of Object.entries(insert)) set(name, key, value)
for (const key of removed) unset(name, key)
return ++revision
},
set: (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value }),
delete: (name: string, key: string) => writer.set(id(name, key), { name, key, value: null }),
keys,
length: (name: string) => keys(name).length,
// Rare (window closed for good, explicit clear) so it goes straight to the database.
clear(name: string) {
writer.drop((row) => row.name === name)
+5 -5
View File
@@ -32,12 +32,12 @@ export type ElectronAPI = {
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
resolveAppPath(appName: string): Promise<string | null>
storeItems(name: string): Promise<{ items: Record<string, string>; revision: number }>
storeUpdate(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
storeGet(name: string, key: string): Promise<string | null>
storeSet(name: string, key: string, value: string): Promise<void>
storeDelete(name: string, key: string): Promise<void>
storeClear(name: string): Promise<void>
onStoreChanged(
cb: (name: string, insert: Record<string, string>, remove: string[], revision: number) => void,
): () => void
storeKeys(name: string): Promise<string[]>
storeLength(name: string): Promise<number>
draftGet(key: string): Promise<string | null>
draftSet(key: string, value: string): Promise<void>
draftDelete(key: string): Promise<void>
+5 -4
View File
@@ -75,11 +75,12 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeGet: (name, key) => invoke("StorageGet", { name, key }),
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
listen("StorageChanged", (event) => cb(event.name, mutable(event.insert), mutable(event.remove), event.revision)),
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
storeLength: (name) => invoke("StorageLength", { name }),
draftGet: (key) => invoke("DraftsGet", { key }),
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
draftDelete: (key) => invoke("DraftsDelete", { key }),
+1 -12
View File
@@ -28,18 +28,7 @@ const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.m
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
window.addEventListener(
"pagehide",
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
{ once: true },
)
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
window.addEventListener("pagehide", () => void runtime.dispose(), { once: true })
runtime.runFork(
Effect.gen(function* () {
@@ -1,36 +1,26 @@
import {
createDraftStore,
createNamespaceStorage,
flushPersisted,
type NamespaceStorage,
type Platform,
} from "@opencode-ai/app/desktop"
import { createDraftStore, type Platform } from "@opencode-ai/app/desktop"
import type { AsyncStorage } from "@solid-primitives/storage"
import type { ElectronAPI } from "../api-types"
import { onBeforeDispose } from "../ipc-client"
export function createDesktopStorage(api: ElectronAPI) {
const namespaces = new Map<string, NamespaceStorage>()
const driver = { items: api.storeItems, update: api.storeUpdate, clear: api.storeClear }
const cache = new Map<string, AsyncStorage>()
const storage: NonNullable<Platform["storage"]> = (name = "default.dat") => {
const cached = namespaces.get(name)
const cached = cache.get(name)
if (cached) return cached
const next = createNamespaceStorage(driver, name)
namespaces.set(name, next)
const next: AsyncStorage = {
getItem: (key) => api.storeGet(name, key),
setItem: (key, value) => api.storeSet(name, key, value),
removeItem: (key) => api.storeDelete(name, key),
clear: () => api.storeClear(name),
key: async (index: number) => (await api.storeKeys(name))[index],
getLength: () => api.storeLength(name),
get length() {
return next.getLength()
},
}
cache.set(name, next)
return next
}
// Dirty stores must serialize into their namespaces before the namespaces are sent; the app's
// own pagehide listener registers after the IPC client's, so it cannot be relied on here.
const flush = () => {
flushPersisted()
return Promise.all([...namespaces.values()].map((namespace) => namespace.flush()))
}
api.onStoreChanged((name, insert, remove, revision) => namespaces.get(name)?.accept(insert, remove, revision))
// Durability boundaries: the window going away, and it leaving the foreground.
onBeforeDispose(flush)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") void flush()
})
return {
storage,
@@ -31,14 +31,6 @@ export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("
factor: Schema.Number,
}) {}
// Another window wrote to a storage namespace; recipients refresh their in-memory copy.
export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("StorageChanged", {
name: Schema.String,
insert: Schema.Record(Schema.String, Schema.String),
remove: Schema.Array(Schema.String),
revision: Schema.Number,
}) {}
export const DesktopEvent = Schema.Union([
DeepLinksOpened,
MenuCommandTriggered,
@@ -47,7 +39,6 @@ export const DesktopEvent = Schema.Union([
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
StorageChanged,
])
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
+21 -12
View File
@@ -1,19 +1,25 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const StorageItems = Rpc.make("StorageItems", {
payload: { name: Schema.String },
success: Schema.Struct({ items: Schema.Record(Schema.String, Schema.String), revision: Schema.Number }),
export const StorageGet = Rpc.make("StorageGet", {
payload: { name: Schema.String, key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const StorageUpdate = Rpc.make("StorageUpdate", {
payload: {
name: Schema.String,
insert: Schema.Record(Schema.String, Schema.String),
remove: Schema.Array(Schema.String),
},
success: Schema.Number,
export const StorageSet = Rpc.make("StorageSet", {
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
})
export const StorageDelete = Rpc.make("StorageDelete", {
payload: { name: Schema.String, key: Schema.String },
})
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
export const StorageKeys = Rpc.make("StorageKeys", {
payload: { name: Schema.String },
success: Schema.Array(Schema.String),
})
export const StorageLength = Rpc.make("StorageLength", {
payload: { name: Schema.String },
success: Schema.Number,
})
export const DraftsGet = Rpc.make("DraftsGet", {
payload: { key: Schema.String },
success: Schema.NullOr(Schema.String),
@@ -32,9 +38,12 @@ export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
})
export const StorageRpcs = RpcGroup.make(
StorageItems,
StorageUpdate,
StorageGet,
StorageSet,
StorageDelete,
StorageClear,
StorageKeys,
StorageLength,
DraftsGet,
DraftsSet,
DraftsDelete,
@@ -1,38 +1,49 @@
import { expect, story } from "../../storybook/playwright/story"
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups")
const group = root.locator('[data-component="collapsed-tool-group"]')
const patches = group.locator('[data-component="apply-patch-tool"]')
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
).toHaveText(/^3 /)
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toBeVisible()
await group.screenshot({ path: info.outputPath("merged.png") })
})
for (const separator of ["shell", "error", "reasoning"]) {
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
for (const tool of ["patch", "edit", "write", "mixed"]) {
story(`merges follow-up ${tool} calls into one stack with a distinct file count`, async ({ mount }, info) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { tool } })
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
const patches = group.locator('[data-component="apply-patch-tool"]')
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await expect(
patches.getByLabel(tool === "mixed" ? "Edit" : `${tool[0].toUpperCase()}${tool.slice(1)}`, { exact: true }),
).toBeVisible()
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Start follow-up patch" }).click()
await expect(group).toHaveAttribute(
"data-timeline-part-ids",
tool === "patch" ? "patch_shell,patch_first,patch_next" : "patch_shell,first_0,first_1,next_0,next_1",
)
await expect(patches).toHaveCount(1)
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(patches).toHaveCount(1)
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await expect(patches.locator('[data-component="file"]')).toHaveCount(2)
await expect(patches.locator('[data-component="file"]').nth(0)).toBeVisible()
await expect(patches.locator('[data-component="file"]').nth(1)).toBeVisible()
await expect(patches.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(2)
await group.screenshot({ path: info.outputPath("merged.png") })
})
for (const separator of ["shell", "error", "reasoning"]) {
story(`does not merge ${tool} calls across an intervening ${separator}`, async ({ mount }) => {
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator, tool } })
await expect(root.locator("[data-file-tool]")).toHaveAttribute("data-file-tool", tool)
await expect(root.locator("[data-file-separator]")).toHaveAttribute("data-file-separator", separator)
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
})
}
}
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
@@ -46,3 +57,33 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
})
for (const placement of ["separate", "grouped"]) {
story(
`preserves mixed file disclosures through append and split in ${placement} timeline rows`,
async ({ mount, page }) => {
await page.setViewportSize({ width: placement === "grouped" ? 390 : 1280, height: 900 })
const root = await mount("current-tool-group--patch-follow-ups", {
args: { tool: "mixed", placement, separator: "reasoning" },
})
const timeline = root.locator('[data-component="session-timeline"]')
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
const stacks = timeline.locator('[data-component="apply-patch-tool"]')
const first = stacks.first().locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await expect(stacks).toHaveCount(1)
if (placement === "grouped") await first.click()
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
await expect(stacks).toHaveCount(1)
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
await expect(stacks).toHaveCount(2)
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
await expect(first).toHaveAttribute("aria-expanded", "true")
const second = stacks.nth(1).locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
await expect(second).toHaveAttribute("aria-expanded", "false")
},
)
}
@@ -92,3 +92,36 @@ story("keeps patch file disclosures independent", async ({ mount }) => {
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(deleted).toHaveAttribute("aria-expanded", "false")
})
for (const placement of ["separate", "grouped"]) {
for (const profile of [
{ tool: "edit", empty: false, noOp: false },
{ tool: "write", empty: false, noOp: false },
{ tool: "write", empty: true, noOp: false },
{ tool: "edit", empty: false, noOp: true },
{ tool: "write", empty: true, noOp: true },
]) {
story(
`keeps ${profile.empty ? "empty " : ""}${profile.tool} input fallback with ${profile.noOp ? "zero-change" : "missing"} metadata in ${placement} rows`,
async ({ mount }) => {
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
args: { ...profile, timeline: true, placement },
})
const timeline = root.locator('[data-component="session-timeline"]')
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
const fallback = timeline.locator(`[data-component="${profile.tool}-tool"]`)
await expect(fallback).toHaveCount(1)
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
if (placement === "grouped") await fallback.getByRole("button", { name: /example\.ts/ }).click()
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
await root.getByRole("button", { name: "Complete file tool" }).click()
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
await expect(fallback.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
await expect(timeline.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
},
)
}
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { currentContentDefaultOpen } from "./current-tool-state"
import { currentContentDefaultOpen, currentToolCanGroupFiles } from "./current-tool-state"
function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantTool {
return {
@@ -17,6 +17,33 @@ function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantToo
}
}
describe("current file grouping eligibility", () => {
test.each(["edit", "write"])("keeps %s input fallbacks unless changed files are available", (name) => {
const unchanged = { file: "src/example.ts", patch: "", status: "modified", additions: 0, deletions: 0 }
expect(currentToolCanGroupFiles(tool(name, [unchanged]))).toBe(false)
expect(currentToolCanGroupFiles(tool(name))).toBe(false)
expect(currentToolCanGroupFiles({ ...tool(name), state: { status: "running", input: {}, metadata: {} } })).toBe(
false,
)
expect(
currentToolCanGroupFiles(
tool(name, [{ ...unchanged, patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1 }]),
),
).toBe(true)
expect(
currentToolCanGroupFiles({
...tool(name),
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "failed" },
metadata: { files: [{ ...unchanged, additions: 1 }] },
},
}),
).toBe(false)
})
})
describe("current content default open", () => {
test("uses the shell disclosure preference", () => {
expect(currentContentDefaultOpen(tool("shell"), true, false)).toBe(true)
@@ -14,6 +14,23 @@ export function currentToolMetadata(tool: SessionMessageAssistantTool): Record<s
return tool.state.metadata ?? empty
}
export function currentToolCanGroupFiles(tool: SessionMessageAssistantTool) {
if (tool.state.status === "error") return false
if (tool.name === "patch") return true
// Keep the input-based renderer when older edit/write results have no file diffs.
if (tool.name !== "edit" && tool.name !== "write") return false
const files = currentToolMetadata(tool).files
if (!Array.isArray(files)) return false
// Empty and unchanged results still need their filename and content preview.
return files.some(
(file) =>
!!file &&
typeof file === "object" &&
(("additions" in file && typeof file.additions === "number" && file.additions > 0) ||
("deletions" in file && typeof file.deletions === "number" && file.deletions > 0)),
)
}
export function currentToolOutput(tool: SessionMessageAssistantTool) {
if (tool.state.status === "running") {
const output = tool.state.metadata.output
@@ -264,44 +264,98 @@ export const CreatedANewFile = {
}
export const FileToolFallbacks = {
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
args: {
tool: "edit",
empty: false,
forceOpen: false,
controlled: true,
timeline: false,
placement: "separate",
noOp: false,
},
argTypes: {
tool: { control: "select", options: ["edit", "write"] },
empty: { control: "boolean" },
forceOpen: { control: "boolean" },
controlled: { control: "boolean" },
timeline: { control: "boolean" },
noOp: { control: "boolean" },
placement: { control: "select", options: ["separate", "grouped"] },
},
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
render: (args: {
tool: string
empty: boolean
forceOpen: boolean
controlled: boolean
timeline: boolean
noOp: boolean
placement: "separate" | "grouped"
}) => {
const [state, setState] = createStore({ completed: false, open: false })
const document = createMemo(() =>
storyDocument([
storyTool(
"tool_file_fallback",
args.tool,
state.completed ? "completed" : "running",
{
path: "src/example.ts",
oldString: "export const before = true\n",
newString: "export const after = true\n",
content: args.empty ? "" : "export const written = true\n",
},
{
metadata:
state.completed && args.noOp
? {
files: [
{
file: "src/example.ts",
patch: createTwoFilesPatch("src/example.ts", "src/example.ts", "", ""),
status: "modified",
additions: 0,
deletions: 0,
},
],
}
: undefined,
},
),
]),
)
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("completed", true)}>
Complete file tool
</button>
<CurrentSessionProviders document={storyDocument([])}>
<ToolDisplay
id="tool_file_fallback"
tool={args.tool}
status={state.completed ? "completed" : "running"}
input={{
path: "src/example.ts",
oldString: "export const before = true\n",
newString: "export const after = true\n",
content: args.empty ? "" : "export const written = true\n",
}}
metadata={{
diagnostics: state.completed
? {
"src/example.ts": [
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
],
}
: {},
}}
open={args.controlled ? state.open : undefined}
onOpenChange={(open) => setState("open", open)}
forceOpen={args.forceOpen}
/>
<CurrentSessionProviders document={document()}>
{args.timeline ? (
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
) : (
<ToolDisplay
id="tool_file_fallback"
tool={args.tool}
status={state.completed ? "completed" : "running"}
input={{
path: "src/example.ts",
oldString: "export const before = true\n",
newString: "export const after = true\n",
content: args.empty ? "" : "export const written = true\n",
}}
metadata={{
diagnostics: state.completed
? {
"src/example.ts": [
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
],
}
: {},
}}
open={args.controlled ? state.open : undefined}
onOpenChange={(open) => setState("open", open)}
forceOpen={args.forceOpen}
/>
)}
</CurrentSessionProviders>
</section>
)
@@ -8,7 +8,12 @@ import type {
} from "@opencode-ai/client/promise"
import { Option, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
import {
currentContentDefaultOpen,
currentToolCanGroupFiles,
currentToolFailed,
currentToolHasLoadedFiles,
} from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
@@ -597,7 +602,7 @@ function groupContent(
detail?: TimelineDetail,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
const flush = () => {
const current = adjacent
const first = current?.refs[0]
@@ -665,8 +670,7 @@ function toolGroupType(
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (currentToolFailed(content)) return undefined
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
if (currentToolCanGroupFiles(content)) return "file"
return undefined
}
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
@@ -684,8 +688,7 @@ function toolGroupType(
)
return undefined
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
if (currentToolCanGroupFiles(content)) return "file"
return undefined
}
@@ -4,7 +4,7 @@ import type {
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -625,7 +625,7 @@ describe("current session timeline rows", () => {
])
})
test("groups adjacent successful patches and leaves failed patches separate", () => {
test("groups adjacent patch, edit, and write calls and leaves failed calls separate", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
{
@@ -681,16 +681,23 @@ describe("current session timeline rows", () => {
type: "tool",
id: "tool_edit_1",
name: "edit",
state: { status: "running", input: {}, metadata: { files: [] } },
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
time: { created: 9 },
},
{
type: "tool",
id: "tool_edit_2",
name: "edit",
state: { status: "running", input: {}, metadata: { files: [] } },
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
time: { created: 10 },
},
{
type: "tool",
id: "tool_write",
name: "write",
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/written.ts")] } },
time: { created: 11 },
},
],
time: { created: 2, completed: 8 },
},
@@ -716,14 +723,11 @@ describe("current session timeline rows", () => {
{
type: "file",
key: "part:msg_assistant:tool_patch_3",
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
},
{
type: "file",
key: "part:msg_assistant:tool_edit_1",
refs: [
{ messageID: "msg_assistant", partID: "tool_patch_3" },
{ messageID: "msg_assistant", partID: "tool_edit_1" },
{ messageID: "msg_assistant", partID: "tool_edit_2" },
{ messageID: "msg_assistant", partID: "tool_write" },
],
},
])
@@ -790,8 +794,8 @@ describe("current session timeline rows", () => {
test.each([
{ shell: false, edit: false, types: ["context"] },
{ shell: true, edit: false, types: ["part", "context"] },
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
{ shell: false, edit: true, types: ["context", "part", "part", "file", "context"] },
{ shell: true, edit: true, types: ["part", "part", "part", "file", "context"] },
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
const source = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
@@ -23,7 +23,7 @@ import type { ContextGroupPart } from "../tools/tool-renderer"
import { SessionRetry } from "../components/session-retry"
import { SessionError } from "../components/session-error"
import { timelineCategory, type TimelineDetail } from "./detail"
import { currentToolFailed } from "../message/current-tool-state"
import { currentToolCanGroupFiles, currentToolFailed } from "../message/current-tool-state"
import {
createReactiveTimelineProjection,
Timeline,
@@ -72,7 +72,7 @@ export function createSessionTimelineRowRenderer(input: {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => {
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
if (content?.type !== "tool" || !currentToolCanGroupFiles(content)) return
const part = `${ref.messageID}:${ref.partID}`
const key = patchGroupKeys.get(part)
if (key && !owners.has(key)) owners.set(key, part)
@@ -223,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
if (open !== undefined) return open
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
if (!["edit", "write"].includes(tools()[0]?.name ?? "") || path !== firstPath()) return false
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
}}
onFileOpenChange={(path, open) => input.disclosure.set(`${row().group.key}:file:${path}`, open)}
@@ -4,6 +4,7 @@ import { createTwoFilesPatch } from "diff"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
import { SessionTimeline } from "../timeline/session-timeline"
export default {
title: "OpenCode/Work/Tool group",
@@ -78,36 +79,59 @@ export const MixedReasoning = {
}
export const PatchFollowUps = {
args: { separator: "none" },
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
render: (args: { separator: string }) => {
args: { separator: "none", tool: "patch", placement: "used" },
argTypes: {
separator: { control: "select", options: ["none", "shell", "error", "reasoning"] },
tool: { control: "select", options: ["patch", "edit", "write", "mixed"] },
placement: { control: "select", options: ["used", "separate", "grouped"] },
},
render: (args: { separator: string; tool: string; placement: "used" | "separate" | "grouped" }) => {
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
const source = (value: number) => `export const value = ${value}\n`
const file = (path: string, before: number, after: number) => ({
file: path,
status: "modified",
additions: 1,
deletions: 1,
patch: createTwoFilesPatch(
path,
path,
`export const value = ${before}\n`,
`export const value = ${after}\n`,
"",
"",
{ context: Infinity },
),
patch: createTwoFilesPatch(path, path, source(before), source(after)),
})
const changes = (next: boolean) => {
const files = next
? [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)]
: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)]
const status = next && state.phase === "running" ? "running" : "completed"
if (args.tool === "patch")
return [
storyTool(
next ? "patch_next" : "patch_first",
"patch",
status,
{},
{ metadata: status === "running" ? {} : { files } },
),
]
return files.map((file, index) => {
const name = args.tool === "mixed" ? (next ? ["patch", "write"] : ["edit", "write"])[index]! : args.tool
return storyTool(
`${next ? "next" : "first"}_${index}`,
name,
status,
name === "patch"
? { patchText: `Update ${file.file}` }
: name === "write"
? { path: file.file, content: source(next && index === 0 ? 2 : 1) }
: {
path: file.file,
oldString: source(next && index === 0 ? 1 : 0),
newString: source(next && index === 0 ? 2 : 1),
},
{ metadata: status === "running" ? {} : { files: [file] } },
)
})
}
const parts = createMemo<ContextGroupPart[]>(() => [
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
storyTool(
"patch_first",
"patch",
"completed",
{},
{
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
},
),
...changes(false),
...(state.phase === "initial"
? []
: [
@@ -115,7 +139,15 @@ export const PatchFollowUps = {
? [storyTool("patch_separator", "shell", "completed", { command: "printf checked" })]
: []),
...(args.separator === "error"
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
? [
storyTool(
"patch_error",
args.tool === "mixed" ? "write" : args.tool,
"error",
{},
{ error: "File change failed" },
),
]
: []),
...(args.separator === "reasoning" && state.reasoning
? [
@@ -126,19 +158,16 @@ export const PatchFollowUps = {
},
]
: []),
storyTool(
"patch_next",
"patch",
state.phase === "running" ? "running" : "completed",
{},
{
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
},
),
...changes(true),
]),
])
const document = createMemo(() => storyDocument(parts()))
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<section
class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6"
data-file-tool={args.tool}
data-file-separator={args.separator}
>
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start follow-up patch
@@ -152,13 +181,20 @@ export const PatchFollowUps = {
</button>
</Show>
</div>
<CurrentSessionProviders document={storyDocument(parts())}>
<CurrentContextToolGroup
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
<CurrentSessionProviders document={document()}>
<Show
when={args.placement !== "used"}
fallback={
<CurrentContextToolGroup
parts={parts()}
busy={state.phase === "running"}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
}
>
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
</Show>
</CurrentSessionProviders>
</section>
)
@@ -45,6 +45,7 @@ import type {
import {
currentToolError,
currentToolHasLoadedFiles,
currentToolCanGroupFiles,
currentToolInput,
currentToolMetadata,
currentToolOutput,
@@ -547,11 +548,10 @@ export function CurrentContextToolGroup(props: {
}
const previous = groups.at(-1)
if (
tool.name === "patch" &&
tool.state.status !== "error" &&
currentToolCanGroupFiles(tool) &&
Array.isArray(previous) &&
previous?.[0]?.name === "patch" &&
previous[0].state.status !== "error"
previous[0] &&
currentToolCanGroupFiles(previous[0])
) {
previous.push(tool)
return groups
@@ -577,7 +577,7 @@ export function CurrentContextToolGroup(props: {
const patchKeys = createMemo(() => {
const keys = new Map<SessionMessageAssistantTool, string>()
items().forEach((item) => {
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
if (!Array.isArray(item) || !item[0] || !currentToolCanGroupFiles(item[0])) return
const key = props.patchGroupKey?.(item) ?? item[0].id
item.forEach((tool) => keys.set(tool, key))
})
@@ -682,7 +682,7 @@ export function CurrentContextToolGroup(props: {
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<Show
when={tool().name === "patch" && tool().state.status !== "error"}
when={currentToolCanGroupFiles(tool())}
fallback={
<ToolDisplay
id={tool().id}
@@ -836,7 +836,7 @@ export function CurrentFileToolGroup(props: {
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const render = ToolRegistry.render("patch") ?? GenericTool
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
const tool = createMemo(() => props.tools[0]?.name ?? "patch")
return (
<div
@@ -1879,7 +1879,11 @@ ToolRegistry.register({
const files = createMemo(() => patchFileGroups(props.metadata.files))
const [expanded, setExpanded] = createSignal<string[]>([])
const title = createMemo(() =>
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
props.tool === "edit"
? i18n.t("ui.messagePart.title.edit")
: props.tool === "write"
? i18n.t("ui.messagePart.title.write")
: i18n.t("ui.tool.patch"),
)
const open = createMemo(() => {
if (!props.fileOpen) return expanded()
+1 -1
View File
@@ -169,7 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running"),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
renaming: data.session.title.pending(session),
}
}
+6 -243
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { OpenCodeEvent, SessionInboxInfo } from "@opencode-ai/client"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import path from "path"
@@ -38,8 +38,6 @@ async function renderSessionTabs(
sessionParents?: Record<string, string>
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
active?: () => Record<string, { type: "running" }>
inbox?: Record<string, SessionInboxInfo[]>
newLocation?: "launch" | "inherit"
launchDirectory?: string
tabsEnabled?: boolean
@@ -71,17 +69,10 @@ async function renderSessionTabs(
const viewWatermarks: number[] = []
const locations: string[] = []
const vcsLocations: string[] = []
let activeRequests = 0
const sessionTimes = Object.fromEntries(
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
)
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session/active") {
activeRequests++
return json({ data: options?.active?.() ?? {} })
}
const inboxID = url.pathname.match(/^\/api\/session\/([^/]+)\/inbox$/)?.[1]
if (inboxID && options?.inbox) return json({ data: options.inbox[inboxID] ?? [] })
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -208,8 +199,6 @@ async function renderSessionTabs(
sessionTimes[sessionID] = time
},
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
disconnect: () => events.disconnect(),
activeRequests: () => activeRequests,
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
@@ -240,28 +229,6 @@ function admitted(sessionID: string, inboxID: string): OpenCodeEvent {
}
}
function execution(
sessionID: string,
state: "started" | "succeeded" | "failed" | "interrupted",
seq: number,
): OpenCodeEvent {
const event = {
id: `evt_${sessionID}_${seq}`,
created: Date.now(),
durable: { aggregateID: sessionID, seq, version: 1 as const },
data: { sessionID },
}
if (state === "failed")
return {
...event,
type: "session.execution.failed",
data: { sessionID, error: { type: "provider.transport", message: "Disconnected" } },
}
if (state === "interrupted")
return { ...event, type: "session.execution.interrupted", data: { sessionID, reason: "user" } }
return { ...event, type: `session.execution.${state}` }
}
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
@@ -834,9 +801,6 @@ test("distinguishes family questions and permissions without clearing them on se
await wait(() => setup.data.session.get("child") !== undefined)
expect(setup.tabs.status("root").attention).toBe(false)
setup.emit(execution("child", "started", 1))
await wait(() => setup.tabs.status("root").busy)
setup.emit({
id: "evt_question",
created: 1,
@@ -880,7 +844,6 @@ test("distinguishes family questions and permissions without clearing them on se
data: { sessionID: "child", id: "frm_question", answer: {} },
})
await wait(() => setup.tabs.status("root").attention === false)
expect(setup.tabs.status("root").busy).toBe(true)
} finally {
await setup.destroy()
}
@@ -970,202 +933,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
}
})
test.each(["shell", "child"])("a completed user shell in %s does not leave an idle tab busy", async (sessionID) => {
const setup = await renderSessionTabs("shell", { persisted: ["shell"], sessionParents: { child: "shell" } })
try {
await wait(() => setup.data.session.get(sessionID) !== undefined)
expect(setup.tabs.status("shell").busy).toBe(false)
setup.emit({
id: "evt_shell_completion",
created: Date.now(),
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
inboxID: "msg_shell_completion",
item: {
type: "synthetic",
delivery: "steer",
payload: {
text: "Shell completed with exit code 0: done",
metadata: { source: "shell", shellID: "sh_done", state: "completed", exit: 0, truncated: false },
},
},
},
})
await wait(() => setup.data.session.pending.list(sessionID).length === 1)
expect(setup.data.session.status(sessionID)).toBe("idle")
expect(setup.tabs.status("shell").busy).toBe(false)
setup.emit({
id: "evt_execution_started",
created: Date.now(),
type: "session.execution.started",
durable: { aggregateID: sessionID, seq: 2, version: 1 },
data: { sessionID },
})
await wait(() => setup.data.session.status(sessionID) === "running")
expect(setup.tabs.status("shell").busy).toBe(true)
setup.emit({
id: "evt_execution_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: sessionID, seq: 3, version: 1 },
data: { sessionID },
})
await wait(() => setup.data.session.status(sessionID) === "idle")
expect(setup.data.session.pending.list(sessionID)).toHaveLength(1)
expect(setup.tabs.status("shell").busy).toBe(false)
setup.emit(admitted(sessionID, "msg_4"))
await wait(() => setup.data.session.pending.list(sessionID).length === 2)
expect(setup.tabs.status("shell").busy).toBe(false)
} finally {
await setup.destroy()
}
})
for (const delivery of ["steer", "queue"] as const) {
for (const item of [
{ type: "user", payload: { text: "Run later" } },
{ type: "synthetic", payload: { text: "Saved context" } },
{ type: "compaction", payload: {} },
{ type: "move", payload: { location: { directory }, projectID: "project" } },
] as const) {
test(`${delivery} ${item.type} inbox admission and removal do not control the tab spinner`, async () => {
const setup = await renderSessionTabs("root", { persisted: ["root"], sessionParents: { child: "root" } })
try {
await wait(() => setup.data.session.get("child") !== undefined)
setup.emit({
id: "evt_pending",
created: Date.now(),
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child", inboxID: "msg_pending", item: { ...item, delivery } },
})
await wait(() => setup.data.session.pending.list("child").length === 1)
expect(setup.tabs.status("root").busy).toBe(false)
setup.emit(execution("child", "started", 2))
await wait(() => setup.tabs.status("root").busy)
setup.emit({
id: "evt_delivered",
created: Date.now(),
type: delivery === "steer" ? "session.inbox.delivered" : "session.inbox.cancelled",
durable: { aggregateID: "child", seq: 3, version: 1 },
data: { sessionID: "child", inboxID: "msg_pending" },
})
await wait(() => setup.data.session.pending.list("child").length === 0)
expect(setup.tabs.status("root").busy).toBe(true)
setup.emit(execution("child", "succeeded", 4))
await wait(() => setup.data.session.status("child") === "idle")
expect(setup.tabs.status("root").busy).toBe(false)
} finally {
await setup.destroy()
}
})
}
}
for (const state of ["succeeded", "failed", "interrupted"] as const) {
test.each(["root", "grandchild"])(
`${state} execution in %s clears busy despite retained input`,
async (sessionID) => {
const setup = await renderSessionTabs("root", {
persisted: ["root", "other"],
sessionParents: { child: "root", grandchild: "child" },
})
try {
await setup.data.session.sync("child", { children: true })
await wait(() => setup.data.session.get("grandchild") !== undefined)
setup.emit(execution(sessionID, "started", 1))
setup.emit(admitted(sessionID, "msg_2"))
await wait(() => setup.data.session.pending.list(sessionID).length === 1)
expect(setup.tabs.status("root").busy).toBe(true)
expect(setup.tabs.status("other").busy).toBe(false)
setup.emit(execution(sessionID, state, 3))
await wait(() => setup.data.session.status(sessionID) === "idle")
expect(setup.data.session.pending.list(sessionID)).toHaveLength(1)
expect(setup.tabs.status("root").busy).toBe(false)
setup.emit(execution(sessionID, "started", 4))
await wait(() => setup.data.session.status(sessionID) === "running")
expect(setup.tabs.status("root").busy).toBe(true)
} finally {
await setup.destroy()
}
},
)
}
test("tab busy state recovers from active snapshots when lifecycle events were missed", async () => {
let active: Record<string, { type: "running" }> = { root: { type: "running" }, child: { type: "running" } }
const setup = await renderSessionTabs("root", {
persisted: ["root"],
sessionParents: { child: "root" },
active: () => active,
inbox: {
root: [
{
id: "msg_saved",
sessionID: "root",
timeCreated: 1,
type: "user",
delivery: "queue",
payload: { text: "Later" },
},
],
},
})
try {
await wait(() => setup.tabs.status("root").busy)
await setup.data.session.pending.sync("root")
expect(setup.data.session.pending.list("root")).toHaveLength(1)
setup.emit(execution("child", "succeeded", 1))
await wait(() => setup.data.session.status("child") === "idle")
expect(setup.tabs.status("root").busy).toBe(true)
// Completion happened while disconnected; no terminal event is delivered to this client.
active = {}
setup.disconnect()
await wait(() => setup.activeRequests() >= 2 && setup.data.session.status("root") === "idle", 5_000)
expect(setup.data.session.pending.list("root")).toHaveLength(1)
expect(setup.tabs.status("root").busy).toBe(false)
active = { child: { type: "running" } }
setup.disconnect()
await wait(() => setup.activeRequests() >= 3 && setup.data.session.status("child") === "running", 5_000)
expect(setup.tabs.status("root").busy).toBe(true)
} finally {
await setup.destroy()
}
})
test("optimistic submission and failed setup do not claim the session is executing", async () => {
const setup = await renderSessionTabs("root", { persisted: ["root"] })
const gate = Promise.withResolvers<void>()
try {
await wait(() => setup.data.session.get("root") !== undefined)
const sending = setup.data.session.prompt({ sessionID: "root", text: "Hello", gate: gate.promise })
expect(setup.data.session.pending.list("root")).toHaveLength(1)
expect(setup.tabs.status("root").busy).toBe(false)
gate.reject(new Error("Setup failed"))
await expect(sending).rejects.toThrow("Setup failed")
expect(setup.data.session.pending.list("root")).toHaveLength(0)
expect(setup.tabs.status("root").busy).toBe(false)
} finally {
gate.resolve()
await setup.destroy()
}
})
test("user prompt admissions pulse a background tab independently of execution", async () => {
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background", { persisted: ["background"] })
try {
@@ -1184,21 +952,16 @@ test("user prompt admissions pulse a background tab independently of execution",
item: { type: "synthetic", payload: { text: "editor context" }, delivery: "steer" },
},
})
await wait(() => setup.data.session.pending.list("background").length === 1)
await Bun.sleep(20)
expect(setup.tabs.status("background").promptPulse).toBe(0)
expect(setup.tabs.status("background").busy).toBe(false)
setup.emit(admitted("background", "msg_1"))
await wait(() => setup.tabs.status("background").promptPulse === 1)
expect(setup.tabs.status("background").busy).toBe(false)
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
setup.emit(execution("background", "started", 2))
await wait(() => setup.tabs.status("background").busy)
setup.emit(admitted("background", "msg_3"))
setup.emit(admitted("background", "msg_2"))
await wait(() => setup.tabs.status("background").promptPulse === 2)
setup.emit(admitted("active", "msg_4"))
setup.emit(admitted("active", "msg_3"))
await Bun.sleep(20)
expect(setup.tabs.status("active").promptPulse).toBe(0)
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })