chore: refactoring and tests, splitting up files (#12495)

This commit is contained in:
Adam
2026-02-06 10:02:31 -06:00
committed by GitHub
parent a4bc883595
commit 2c58dd6203
117 changed files with 9457 additions and 5827 deletions

View File

@@ -0,0 +1,195 @@
import {
type Config,
type Path,
type PermissionRequest,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient>
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
]
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
input.setGlobalStore("ready", true)
}
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
return input.reduce<Record<string, T[]>>((acc, item) => {
if (!item?.id || !item.sessionID) return acc
const list = acc[item.sessionID]
if (list) list.push(item)
if (!list) acc[item.sessionID] = [item]
return acc
}, {})
}
export async function bootstrapDirectory(input: {
directory: string
sdk: ReturnType<typeof createOpencodeClient>
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
}) {
input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
}
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const project = getFilename(input.directory)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: `Failed to reload ${project}`, description: message })
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
}

View File

@@ -0,0 +1,263 @@
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
type ChildOptions,
type DirState,
type IconCache,
type MetaCache,
type ProjectMeta,
type State,
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: {
owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onDispose: (directory: string) => void
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>()
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
const lifecycle = new Map<string, DirState>()
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>()
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction()
}
const pin = (directory: string) => {
if (!directory) return
pins.set(directory, (pins.get(directory) ?? 0) + 1)
mark(directory)
}
const unpin = (directory: string) => {
if (!directory) return
const next = (pins.get(directory) ?? 0) - 1
if (next > 0) {
pins.set(directory, next)
return
}
pins.delete(directory)
runEviction()
}
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinForOwner = (directory: string) => {
const current = getOwner()
if (!current) return
if (current === input.owner) return
const key = current as object
const set = ownerPins.get(key)
if (set?.has(directory)) return
if (set) set.add(directory)
if (!set) ownerPins.set(key, new Set([directory]))
pin(directory)
onCleanup(() => {
const set = ownerPins.get(key)
if (set) {
set.delete(directory)
if (set.size === 0) ownerPins.delete(key)
}
unpin(directory)
})
}
function disposeDirectory(directory: string) {
if (
!canDisposeDirectory({
directory,
hasStore: !!children[directory],
pinned: pinned(directory),
booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(directory),
})
) {
return false
}
vcsCache.delete(directory)
metaCache.delete(directory)
iconCache.delete(directory)
lifecycle.delete(directory)
const dispose = disposers.get(directory)
if (dispose) {
dispose()
disposers.delete(directory)
}
delete children[directory]
input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true
}
function runEviction() {
const stores = Object.keys(children)
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
stores,
state: lifecycle,
pins: new Set(stores.filter(pinned)),
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
})
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
input.incrementEvictions()
}
}
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
const vcsReady = vcs[3]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
const meta = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "project", ["project.v1"]),
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
if (!meta) throw new Error("Failed to create persisted project metadata")
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }),
),
)
if (!icon) throw new Error("Failed to create persisted project icon")
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
createRoot((dispose) => {
const child = createStore<State>({
project: "",
projectMeta: meta[0].value,
icon: icon[0].value,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: vcsStore.value,
limit: 5,
message: {},
part: {},
})
children[directory] = child
disposers.set(directory, dispose)
createEffect(() => {
if (!vcsReady()) return
const cached = vcsStore.value
if (!cached?.branch) return
child[1]("vcs", (value) => value ?? cached)
})
createEffect(() => {
child[1]("projectMeta", meta[0].value)
})
createEffect(() => {
child[1]("icon", icon[0].value)
})
})
runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
}
mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
return childStore
}
function child(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
pinForOwner(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
const next = {
...previous,
...patch,
icon,
commands,
}
cached.setStore("value", next)
setStore("projectMeta", next)
}
function projectIcon(directory: string, value: string | undefined) {
const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(directory)
if (!cached) return
if (store.icon === value) return
cached.setStore("value", value)
setStore("icon", value)
}
return {
children,
ensureChild,
child,
projectMeta,
projectIcon,
mark,
pin,
unpin,
pinned,
disposeDirectory,
runEviction,
vcsCache,
metaCache,
iconCache,
}
}

View File

@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: 1,
updated: 1,
archived: input.archived,
},
}) as Session
const userMessage = (id: string, sessionID: string) =>
({
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
}) as Message
const textPart = (id: string, sessionID: string, messageID: string) =>
({
id,
sessionID,
messageID,
type: "text",
text: id,
}) as Part
const baseState = (input: Partial<State> = {}) =>
({
status: "complete",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider: {} as State["provider"],
config: {} as State["config"],
path: { directory: "/tmp" } as State["path"],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 10,
message: {},
part: {},
...input,
}) as State
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const project = [{ id: "a" }, { id: "c" }] as Project[]
let refreshCount = 0
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "b" } },
project,
refresh: () => {
refreshCount += 1
},
setGlobalProject(next) {
if (typeof next === "function") next(project)
},
})
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
expect(refreshCount).toBe(0)
})
test("handles global.disposed by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
})
describe("applyDirectoryEvent", () => {
test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "b" })],
sessionTotal: 1,
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
expect(store.sessionTotal).toBe(2)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.sessionTotal).toBe(2)
})
test("cleans session caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(
baseState({
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
sessionTotal: 2,
message: { ses_1: [message] },
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
session_diff: { ses_1: [] },
todo: { ses_1: [] },
permission: { ses_1: [] },
question: { ses_1: [] },
session_status: { ses_1: { type: "busy" } },
}),
)
applyDirectoryEvent({
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
expect(store.sessionTotal).toBe(1)
expect(store.message.ses_1).toBeUndefined()
expect(store.part[message.id]).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.permission.ses_1).toBeUndefined()
expect(store.question.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
})
test("routes disposal and lsp events to side-effect handlers", () => {
const [store, setStore] = createStore(baseState())
const pushes: string[] = []
let lspLoads = 0
applyDirectoryEvent({
event: { type: "server.instance.disposed" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
applyDirectoryEvent({
event: { type: "lsp.updated" },
store,
setStore,
push(directory) {
pushes.push(directory)
},
directory: "/tmp",
loadLsp() {
lspLoads += 1
},
})
expect(pushes).toEqual(["/tmp"])
expect(lspLoads).toBe(1)
})
})

View File

@@ -0,0 +1,319 @@
import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
})
return
}
input.setGlobalProject((draft) => {
draft.splice(result.index, 0, properties)
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
store.session_diff[sessionID] !== undefined ||
store.todo[sessionID] !== undefined ||
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
if (!hasAny) return
setStore(
produce((draft) => {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
vcsCache?: VcsCache
}) {
const event = input.event
switch (event.type) {
case "server.instance.disposed": {
input.push(input.directory)
return
}
case "session.created": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
break
}
case "session.updated": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
if (result.found) {
input.setStore("session", result.index, reconcile(info))
break
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
break
}
case "session.deleted": {
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
break
}
input.setStore(
"message",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
}),
)
break
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
input.setStore(
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[props.messageID]
}),
)
break
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part))
break
}
input.setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) {
input.setStore(
produce((draft) => {
const list = draft.part[props.messageID]
if (!list) return
const next = Binary.search(list, props.partID, (p) => p.id)
if (!next.found) return
list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID]
}),
)
}
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const next = { branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = input.store.permission[permission.sessionID]
if (!permissions) {
input.setStore("permission", permission.sessionID, [permission])
break
}
const result = Binary.search(permissions, permission.id, (p) => p.id)
if (result.found) {
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
break
}
input.setStore(
"permission",
permission.sessionID,
produce((draft) => {
draft.splice(result.index, 0, permission)
}),
)
break
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
const permissions = input.store.permission[props.sessionID]
if (!permissions) break
const result = Binary.search(permissions, props.requestID, (p) => p.id)
if (!result.found) break
input.setStore(
"permission",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
break
}
const result = Binary.search(questions, question.id, (q) => q.id)
if (result.found) {
input.setStore("question", question.sessionID, result.index, reconcile(question))
break
}
input.setStore(
"question",
question.sessionID,
produce((draft) => {
draft.splice(result.index, 0, question)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = input.store.question[props.sessionID]
if (!questions) break
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (!result.found) break
input.setStore(
"question",
props.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
input.loadLsp()
break
}
}
}

View File

@@ -0,0 +1,28 @@
import type { DisposeCheck, EvictPlan } from "./types"
export function pickDirectoriesToEvict(input: EvictPlan) {
const overflow = Math.max(0, input.stores.length - input.max)
let pendingOverflow = overflow
const sorted = input.stores
.filter((dir) => !input.pins.has(dir))
.slice()
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
const output: string[] = []
for (const dir of sorted) {
const last = input.state.get(dir)?.lastAccessAt ?? 0
const idle = input.now - last >= input.ttl
if (!idle && pendingOverflow <= 0) continue
output.push(dir)
if (pendingOverflow > 0) pendingOverflow -= 1
}
return output
}
export function canDisposeDirectory(input: DisposeCheck) {
if (!input.directory) return false
if (!input.hasStore) return false
if (input.pinned) return false
if (input.booting) return false
if (input.loadingSessions) return false
return true
}

View File

@@ -0,0 +1,83 @@
type QueueInput = {
paused: () => boolean
bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void
}
export function createRefreshQueue(input: QueueInput) {
const queued = new Set<string>()
let root = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => {
if (queued.size === 0) return [] as string[]
const items: string[] = []
for (const item of queued) {
queued.delete(item)
items.push(item)
if (items.length >= count) break
}
return items
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void drain()
}, 0)
}
const push = (directory: string) => {
if (!directory) return
queued.add(directory)
if (input.paused()) return
schedule()
}
const refresh = () => {
root = true
if (input.paused()) return
schedule()
}
async function drain() {
if (running) return
running = true
try {
while (true) {
if (input.paused()) return
if (root) {
root = false
await input.bootstrap()
await tick()
continue
}
const dirs = take(2)
if (dirs.length === 0) return
await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
await tick()
}
} finally {
running = false
if (input.paused()) return
if (root || queued.size) schedule()
}
}
return {
push,
refresh,
clear(directory: string) {
queued.delete(directory)
},
dispose() {
if (!timer) return
clearTimeout(timer)
timer = undefined
},
}
}

View File

@@ -0,0 +1,26 @@
import type { RootLoadArgs } from "./types"
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
try {
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
return {
data: result.data,
limit: input.limit,
limited: true,
} as const
} catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,
limit: input.limit,
limited: false,
} as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
return input.count + 1
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
({
id: input.id,
parentID: input.parentID,
time: {
created: input.created,
updated: input.updated,
archived: input.archived,
},
}) as Session
describe("trimSessions", () => {
test("keeps base roots and recent roots beyond the limit", () => {
const now = 1_000_000
const list = [
session({ id: "a", created: now - 100_000 }),
session({ id: "b", created: now - 90_000 }),
session({ id: "c", created: now - 80_000 }),
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
session({ id: "e", created: now - 60_000, archived: now - 10 }),
]
const result = trimSessions(list, { limit: 2, permission: {}, now })
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
})
test("keeps children when root is kept, permission exists, or child is recent", () => {
const now = 1_000_000
const list = [
session({ id: "root-1", created: now - 1000 }),
session({ id: "root-2", created: now - 2000 }),
session({ id: "z-root", created: now - 30_000_000 }),
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
]
const result = trimSessions(list, {
limit: 2,
permission: {
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
},
now,
})
expect(result.map((x) => x.id)).toEqual([
"child-kept-by-permission",
"child-kept-by-recency",
"child-kept-by-root",
"root-1",
"root-2",
])
})
})

View File

@@ -0,0 +1,56 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
if (seen.has(session.id)) continue
seen.add(session.id)
if (sessionUpdatedAt(session) <= cutoff) continue
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
if (index === -1) selected.push(session)
if (index !== -1) selected.splice(index, 0, session)
if (selected.length > limit) selected.pop()
}
return selected
}
export function trimSessions(
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
const all = input
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
const children = all.filter((s) => !!s.parentID)
const base = roots.slice(0, limit)
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
const keepRoots = [...base, ...recent]
const keepRootIds = new Set(keepRoots.map((s) => s.id))
const keepChildren = children.filter((s) => {
if (s.parentID && keepRootIds.has(s.parentID)) return true
const perms = options.permission[s.id] ?? []
if (perms.length > 0) return true
return sessionUpdatedAt(s) > cutoff
})
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
}

View File

@@ -0,0 +1,134 @@
import type {
Agent,
Command,
Config,
FileDiff,
LspStatus,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
Project,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
export type ProjectMeta = {
name?: string
icon?: {
override?: string
color?: string
}
commands?: {
start?: string
}
}
export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
}
export type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
ready: Accessor<boolean>
}
export type MetaCache = {
store: Store<{ value: ProjectMeta | undefined }>
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
ready: Accessor<boolean>
}
export type IconCache = {
store: Store<{ value: string | undefined }>
setStore: SetStoreFunction<{ value: string | undefined }>
ready: Accessor<boolean>
}
export type ChildOptions = {
bootstrap?: boolean
}
export type DirState = {
lastAccessAt: number
}
export type EvictPlan = {
stores: string[]
state: Map<string, DirState>
pins: Set<string>
max: number
ttl: number
now: number
}
export type DisposeCheck = {
directory: string
hasStore: boolean
pinned: boolean
booting: boolean
loadingSessions: boolean
}
export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
}
export type RootLoadResult = {
data?: Session[]
limit: number
limited: boolean
}
export const MAX_DIR_STORES = 30
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
export const SESSION_RECENT_LIMIT = 50

View File

@@ -0,0 +1,25 @@
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}