From 2508a74956c783d2d6cbf7587adb48797ac13234 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 14 Jul 2026 17:32:30 -0400 Subject: [PATCH] refactor(tui): make data sync owner-driven --- packages/plugin/src/v2/tui/context.ts | 27 +- .../tui/src/component/dialog-integration.tsx | 18 +- packages/tui/src/component/dialog-skill.tsx | 2 +- packages/tui/src/component/prompt/index.tsx | 2 +- packages/tui/src/component/prompt/move.tsx | 4 +- packages/tui/src/context/data.tsx | 578 ++++++++++-------- packages/tui/src/context/location.tsx | 28 +- packages/tui/src/routes/session/index.tsx | 22 +- packages/tui/src/routes/session/rows.ts | 20 +- packages/tui/test/cli/tui/data.test.tsx | 225 +++---- 10 files changed, 519 insertions(+), 407 deletions(-) diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index 5e33b03079..f4a2fb9a39 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -24,7 +24,8 @@ import type { JSX } from "@opentui/solid" interface LocationCollection { list(location?: LocationRef): Value[] | undefined - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void } export interface Data { @@ -42,37 +43,45 @@ export interface Data { status(sessionID: string): "idle" | "running" readonly pending: { list(sessionID: string): SessionPendingInfo[] - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void readonly message: { list(sessionID: string): SessionMessageInfo[] get(sessionID: string, messageID: string): SessionMessageInfo | undefined - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } readonly permission: { list(sessionID: string): PermissionV2Request[] | undefined - refresh(sessionID: string): Promise + sync(sessionID: string): Promise + invalidate(sessionID: string): void } readonly form: { list(sessionID: string, location?: LocationRef): Array | undefined - refresh(sessionID: string, location?: LocationRef): Promise + sync(sessionID: string, location?: LocationRef): Promise + invalidate(sessionID: string, location?: LocationRef): void } } readonly project: { readonly permission: { list(projectID: string): PermissionSavedInfo[] | undefined - refresh(projectID: string): Promise + sync(projectID: string): Promise + invalidate(projectID: string): void } } readonly shell: { list(location?: LocationRef): ShellInfo[] get(id: string): ShellInfo | undefined - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void } readonly location: { default(): LocationRef - refresh(location?: LocationRef): Promise + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void readonly agent: LocationCollection readonly command: LocationCollection readonly integration: LocationCollection diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 58b18e16f4..218e2cd220 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -467,11 +467,10 @@ async function connected( toast: ReturnType, onConnected?: OnIntegrationConnected, ) { - await Promise.all([ - data.location.integration.refresh(), - data.location.model.refresh(), - data.location.provider.refresh(), - ]) + data.location.integration.invalidate() + data.location.model.invalidate() + data.location.provider.invalidate() + await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Connected ${integration.name}` }) if (onConnected) { onConnected(providerID(data, integration.id)) @@ -498,11 +497,10 @@ async function disconnected( dialog: ReturnType, toast: ReturnType, ) { - await Promise.all([ - data.location.integration.refresh(), - data.location.model.refresh(), - data.location.provider.refresh(), - ]) + data.location.integration.invalidate() + data.location.model.invalidate() + data.location.provider.invalidate() + await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Disconnected ${name}` }) dialog.clear() } diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 3826a5a3c5..7387012dee 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -25,7 +25,7 @@ export function DialogSkill(props: DialogSkillProps) { .then(async () => { const current = data.location.skill.list(props.location) if (current) return current - await data.location.skill.refresh(props.location) + await data.location.skill.sync(props.location) return data.location.skill.list(props.location) ?? [] }) // Catch so the rejected resource never reaches the memo below: reading diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7d8dec1379..4a66a03ef1 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1055,7 +1055,7 @@ export function Prompt(props: PromptProps) { } else { move.startSubmit() if (!session) { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) session = data.session.get(sessionID) } if (session?.agent !== agent.id) { diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index cc63f5ed5f..a1c0471e94 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -42,7 +42,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const directory = result.directory if (!directory) throw new Error("No project copy directory returned") - // Call a location-based route to make sure it's bootstrapped before moving on. + // Call a location-based route to initialize it before moving on. await client.api.location.get({ location: { directory } }) setProgress("Creating session") @@ -139,7 +139,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess async function resolveSession(sessionID: string) { const session = data.session.get(sessionID) if (session) return session - await data.session.refresh(sessionID).catch(() => undefined) + await data.session.sync(sessionID).catch(() => undefined) return data.session.get(sessionID) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 2506023fbe..04ec20f38c 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,7 +1,7 @@ // Client data layer: apply server events and cache API reads into a Solid store. // Prefer straightforward projection. Do not add generation counters, stale-response // merges, live/history overlays, or other race machinery here—last write wins. -// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. +// Reconnect invalidates cached reads; active UI owners decide what to sync again. import type { AgentInfo, @@ -31,7 +31,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" -import { createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -66,11 +66,10 @@ type Store = { // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record - status: Record + active: Record message: Record pending: Record input: Record - compaction: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record @@ -89,6 +88,37 @@ function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } +function createSync() { + const state = new Map>() + return { + run(key: string, load: () => Promise) { + const active = state.get(key) + if (active === true) return Promise.resolve() + if (active) return active + const pending = load() + .then(() => { + if (state.get(key) === pending) state.set(key, true) + }) + .finally(() => { + if (state.get(key) === pending) state.delete(key) + }) + state.set(key, pending) + return pending + }, + complete(key: string) { + if (state.has(key)) return + state.set(key, true) + }, + invalidate(key?: string) { + if (key) { + state.delete(key) + return + } + state.clear() + }, + } +} + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { @@ -96,11 +126,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ session: { info: {}, family: {}, - status: {}, + active: {}, message: {}, pending: {}, input: {}, - compaction: {}, permission: {}, form: {}, }, @@ -115,16 +144,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - let bootstrapping: Promise | undefined - let connected = false + const sync = createSync() - function setSessionStatus(sessionID: string, status: DataSessionStatus) { - setStore("session", "status", sessionID, status) - } - - function addCompaction(sessionID: string, inputID: string) { - if (store.session.compaction[sessionID]?.includes(inputID)) return - setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID]) + function setSessionActive(sessionID: string, status: DataSessionStatus) { + setStore("session", "active", sessionID, status) } function addPending(item: SessionPendingInfo) { @@ -142,16 +165,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } - function removeCompaction(sessionID: string, inputID?: string) { - if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return - setStore( - "session", - "compaction", - sessionID, - store.session.compaction[sessionID].filter((id) => id !== inputID), - ) - } - const message = { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( @@ -226,7 +239,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return current } - // Register one session into the family index. Idempotent: refreshing an + // Register one session into the family index. Idempotent: syncing an // existing session never duplicates its ID. When a tentative family keyed by // sessionID exists (descendants arrived while sessionID's own info was // absent) but sessionID turns out to have a parent, fold the orphan subtree @@ -254,15 +267,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function removeSession(sessionID: string) { messageIndex.delete(sessionID) + sync.invalidate(`session:${sessionID}`) + sync.invalidate(`session.pending:${sessionID}`) + sync.invalidate(`session.message:${sessionID}`) + sync.invalidate(`session.permission:${sessionID}`) + sync.invalidate(`session.form:${sessionID}:`) setStore( "session", produce((draft) => { delete draft.info[sessionID] - delete draft.status[sessionID] + delete draft.active[sessionID] delete draft.message[sessionID] delete draft.pending[sessionID] delete draft.input[sessionID] - delete draft.compaction[sessionID] delete draft.permission[sessionID] delete draft.form[sessionID] for (const [rootID, family] of Object.entries(draft.family)) { @@ -277,7 +294,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function handleEvent(event: OpenCodeEvent) { switch (event.type) { case "session.created": - void result.session.refresh(event.data.sessionID) + result.session.invalidate(event.data.sessionID) + void result.session.sync(event.data.sessionID) break case "session.deleted": removeSession(event.data.sessionID) @@ -290,19 +308,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "catalog.updated": - void Promise.all([ - result.location.model.refresh(event.location), - result.location.provider.refresh(event.location), - ]) + result.location.model.invalidate(event.location) + result.location.provider.invalidate(event.location) + void Promise.all([result.location.model.sync(event.location), result.location.provider.sync(event.location)]) break case "agent.updated": - void result.location.agent.refresh(event.location) + result.location.agent.invalidate(event.location) + void result.location.agent.sync(event.location) break case "command.updated": - void result.location.command.refresh(event.location) + result.location.command.invalidate(event.location) + void result.location.command.sync(event.location) break case "skill.updated": - void result.location.skill.refresh(event.location) + result.location.skill.invalidate(event.location) + void result.location.skill.sync(event.location) break case "session.agent.selected": if (store.session.info[event.data.sessionID]) @@ -666,7 +686,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.execution.started": - setSessionStatus(event.data.sessionID, "running") + setSessionActive(event.data.sessionID, "running") break case "session.compaction.admitted": addPending({ @@ -676,11 +696,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ timeCreated: event.created, type: "compaction", }) - addCompaction(event.data.sessionID, event.data.inputID) break case "session.compaction.started": removePending(event.data.sessionID, event.data.inputID) - removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID ?? messageIDFromEvent(event.id), @@ -696,7 +714,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.execution.succeeded": case "session.execution.failed": case "session.execution.interrupted": - setSessionStatus(event.data.sessionID, "idle") + setSessionActive(event.data.sessionID, "idle") message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -758,7 +776,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.compaction.failed": removePending(event.data.sessionID, event.data.inputID) - removeCompaction(event.data.sessionID, event.data.inputID) message.update(event.data.sessionID, (draft, index) => { const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") const current = draft[position] @@ -837,22 +854,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "reference.updated": - void result.location.reference.refresh() + result.location.reference.invalidate() + void result.location.reference.sync() break case "integration.updated": + result.location.integration.invalidate(event.location) + result.location.model.invalidate(event.location) + result.location.provider.invalidate(event.location) void Promise.all([ - result.location.integration.refresh(event.location), - result.location.model.refresh(event.location), - result.location.provider.refresh(event.location), + result.location.integration.sync(event.location), + result.location.model.sync(event.location), + result.location.provider.sync(event.location), ]) break // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, - // so the mcp list refreshes here rather than off integration.updated. + // so the mcp list syncs here rather than off integration.updated. case "mcp.status.changed": - void result.location.mcp.server.refresh(event.location) + result.location.mcp.server.invalidate(event.location) + void result.location.mcp.server.sync(event.location) break case "mcp.resources.changed": - void result.location.mcp.resource.refresh(event.location) + result.location.mcp.resource.invalidate(event.location) + void result.location.mcp.resource.sync(event.location) break } } @@ -883,7 +906,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) }, status(sessionID: string) { - return store.session.status[sessionID] ?? "idle" + return store.session.active[sessionID] ?? "idle" }, input: { list(sessionID: string) { @@ -893,38 +916,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, - compaction: { - list(sessionID: string) { - return store.session.compaction[sessionID] ?? [] - }, - async refresh(sessionID: string) { - await result.session.pending.refresh(sessionID) - }, - }, pending: { list(sessionID: string) { return store.session.pending[sessionID] ?? [] }, - async refresh(sessionID: string) { - const pending = await client.api.session.pending.list({ sessionID }) - setStore("session", "pending", sessionID, reconcile(pending)) - setStore( - "session", - "input", - sessionID, - reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), - ) - setStore( - "session", - "compaction", - sessionID, - reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)), - ) + sync(sessionID: string) { + return sync.run(`session.pending:${sessionID}`, async () => { + const pending = await client.api.session.pending.list({ sessionID }) + setStore("session", "pending", sessionID, reconcile(pending)) + setStore( + "session", + "input", + sessionID, + reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)), + ) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.pending:${sessionID}`) }, }, - async refresh(sessionID: string) { - setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) - registerSession(sessionID) + sync(sessionID: string) { + return sync.run(`session:${sessionID}`, async () => { + setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) + registerSession(sessionID) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session:${sessionID}`) }, message: { list(sessionID: string) { @@ -935,18 +954,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const position = messageIndex.get(sessionID)?.get(messageID) return position === undefined ? undefined : messages?.[position] }, - async refresh(sessionID: string) { - const messages = (await client.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed() - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, reconcile(messages)) + sync(sessionID: string) { + return sync.run(`session.message:${sessionID}`, async () => { + const messages = ( + await client.api.message.list({ sessionID, limit: 200, order: "desc" }) + ).data.toReversed() + messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) + setStore("session", "message", sessionID, reconcile(messages)) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.message:${sessionID}`) }, }, permission: { list(sessionID: string) { return store.session.permission[sessionID] }, - async refresh(sessionID: string) { - setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID })) + sync(sessionID: string) { + return sync.run(`session.permission:${sessionID}`, async () => { + setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID })) + }) + }, + invalidate(sessionID: string) { + sync.invalidate(`session.permission:${sessionID}`) }, }, form: { @@ -957,23 +988,33 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const key = locationKey(ref) return forms?.filter((form) => form.location && locationKey(form.location) === key) }, - async refresh(sessionID: string, ref?: LocationRef) { - if (sessionID === "global") { - const response = await client.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) }) - const location = { - directory: response.location.directory, - workspaceID: response.location.workspaceID, + sync(sessionID: string, ref?: LocationRef) { + const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}` + return sync.run(key, async () => { + if (sessionID === "global") { + const response = await client.api.form.request.list({ + location: locationQuery(ref ?? defaultLocation()), + }) + const location = { + directory: response.location.directory, + workspaceID: response.location.workspaceID, + } + const locationID = locationKey(location) + setStore("session", "form", sessionID, [ + ...(store.session.form[sessionID] ?? []).filter( + (form) => form.location && locationKey(form.location) !== locationID, + ), + ...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), + ]) + return } - const key = locationKey(location) - setStore("session", "form", sessionID, [ - ...(store.session.form[sessionID] ?? []).filter( - (form) => form.location && locationKey(form.location) !== key, - ), - ...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })), - ]) - return - } - setStore("session", "form", sessionID, await client.api.form.list({ sessionID })) + setStore("session", "form", sessionID, await client.api.form.list({ sessionID })) + }) + }, + invalidate(sessionID: string, ref?: LocationRef) { + sync.invalidate( + `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`, + ) }, }, }, @@ -982,8 +1023,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ list(projectID: string) { return store.project.permission[projectID] }, - async refresh(projectID: string) { - setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID })) + sync(projectID: string) { + return sync.run(`project.permission:${projectID}`, async () => { + setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID })) + }) + }, + invalidate(projectID: string) { + sync.invalidate(`project.permission:${projectID}`) }, }, }, @@ -996,53 +1042,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .map((data) => data.shell?.[id]) .find((shell) => shell !== undefined) }, - async refresh(ref?: LocationRef) { - const result = await client.api.shell.list({ location: locationQuery(ref) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - shell: Object.fromEntries(result.data.map((info) => [info.id, info])), + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.shell:${id}`, async () => { + const response = await client.api.shell.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + shell: Object.fromEntries(response.data.map((info) => [info.id, info])), + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`) + }, }, location: { default() { return defaultLocation() }, - async refresh(ref?: LocationRef) { - const location = await client.api.location.get({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(location) - if (!store.location[key]) setStore("location", key, {}) - if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + async sync(ref?: LocationRef) { + const current = ref ?? defaultLocation() + await sync.run(`location:${locationKey(current)}`, async () => { + const location = await client.api.location.get({ location: locationQuery(current) }) + const key = locationKey(location) + if (!store.location[key]) setStore("location", key, {}) + if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + }) + const location = ref ?? defaultLocation() + await Promise.all([ + result.location.agent.sync(location), + result.location.command.sync(location), + result.location.integration.sync(location), + result.location.mcp.server.sync(location), + result.location.mcp.resource.sync(location), + result.location.model.sync(location), + result.location.provider.sync(location), + result.location.reference.sync(location), + result.location.skill.sync(location), + result.shell.sync(location), + result.session.form.sync("global", location), + ]) + }, + invalidate(ref?: LocationRef) { + const location = ref ?? defaultLocation() + sync.invalidate(`location:${locationKey(location)}`) + result.location.agent.invalidate(location) + result.location.command.invalidate(location) + result.location.integration.invalidate(location) + result.location.mcp.server.invalidate(location) + result.location.mcp.resource.invalidate(location) + result.location.model.invalidate(location) + result.location.provider.invalidate(location) + result.location.reference.invalidate(location) + result.location.skill.invalidate(location) + result.shell.invalidate(location) + result.session.form.invalidate("global", location) }, agent: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.agent }, - async refresh(ref?: LocationRef) { - const result = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], agent: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.agent:${id}`, async () => { + const response = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], agent: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`) }, }, command: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.command }, - async refresh(ref?: LocationRef) { - const result = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], command: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.command:${id}`, async () => { + const response = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], command: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`) }, }, integration: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.integration }, - async refresh(ref?: LocationRef) { - const result = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], integration: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.integration:${id}`, async () => { + const response = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], integration: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`) }, }, mcp: { @@ -1050,180 +1152,150 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server }, - async refresh(ref?: LocationRef) { - const result = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - mcp: { ...store.location[key]?.mcp, server: result.data }, + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.mcp.server:${id}`, async () => { + const response = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, server: response.data }, + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`) + }, }, resource: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource }, - async refresh(ref?: LocationRef) { - const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { - ...store.location[key], - mcp: { ...store.location[key]?.mcp, resource: result.data.resources }, + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.mcp.resource:${id}`, async () => { + const response = await client.api.mcp.resource.catalog({ + location: locationQuery(ref ?? defaultLocation()), + }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + mcp: { ...store.location[key]?.mcp, resource: response.data.resources }, + }) }) }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`) + }, }, }, model: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.model }, - async refresh(ref?: LocationRef) { - const result = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], model: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.model:${id}`, async () => { + const response = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], model: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`) }, }, provider: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.provider }, - async refresh(ref?: LocationRef) { - const result = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], provider: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.provider:${id}`, async () => { + const response = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], provider: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`) }, }, reference: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.reference }, - async refresh(ref?: LocationRef) { - const result = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], reference: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.reference:${id}`, async () => { + const response = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], reference: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`) }, }, skill: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.skill }, - async refresh(ref?: LocationRef) { - const result = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) }) - const key = locationKey(result.location) - setStore("location", key, { ...store.location[key], skill: result.data }) + sync(ref?: LocationRef) { + const id = locationKey(ref ?? defaultLocation()) + return sync.run(`location.skill:${id}`, async () => { + const response = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(response.location) + setStore("location", key, { ...store.location[key], skill: response.data }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`) }, }, }, } result satisfies Plugin.Context["data"] - async function bootstrap() { - if (bootstrapping) return bootstrapping - bootstrapping = Promise.allSettled([ - client.api.session - .list({ - limit: 50, - order: "desc", - directory: defaultLocation().directory, - workspace: defaultLocation().workspaceID, - }) - .then((response) => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of response.data) draft[session.id] = session - }), - ) - for (const session of response.data) registerSession(session.id) - }), - client.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { - const permissions = response.data.reduce>( - (result, request) => ({ - ...result, - [request.sessionID]: [...(result[request.sessionID] ?? []), request], - }), - {}, - ) - setStore("session", "permission", reconcile(permissions)) - }), - client.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { - const location = { - directory: response.location.directory, - workspaceID: response.location.workspaceID, - } - const forms = response.data.reduce>( - (result, form) => ({ - ...result, - [form.sessionID]: [ - ...(result[form.sessionID] ?? []), - form.sessionID === "global" ? { ...form, location } : form, - ], - }), - {}, - ) - setStore("session", "form", reconcile(forms)) - }), - result.location.refresh(), - result.location.agent.refresh(), - result.location.integration.refresh(), - result.location.mcp.server.refresh(), - result.location.mcp.resource.refresh(), - result.location.model.refresh(), - result.location.provider.refresh(), - result.location.reference.refresh(), - result.location.command.refresh(), - result.location.skill.refresh(), - result.shell.refresh(), - ]) - .then(async (settled) => { - for (const failure of settled.filter((item) => item.status === "rejected")) - console.error("Failed to refresh default location data", failure.reason) - const key = locationKey(defaultLocation()) - const locations = new Map( - Object.values(store.session.info).map( - (session) => [locationKey(session.location), session.location] as const, - ), - ) - const refreshed = await Promise.allSettled( - Array.from(locations) - .filter(([location]) => location !== key) - .map(([, location]) => result.session.form.refresh("global", location)), - ) - for (const failure of refreshed.filter((item) => item.status === "rejected")) - console.error("Failed to refresh global forms", failure.reason) - }) - .finally(() => { - bootstrapping = undefined - }) - return bootstrapping - } - - function refreshActive() { - void client.api.session - .active() - .then((active) => { - setStore( - "session", - "status", - reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), - ) - }) - .catch(() => undefined) - } + createEffect(() => { + if (client.connection.status() === "connected") return + sync.invalidate() + }) onCleanup( client.event.listen(({ details }) => { if (details.type === "server.connected") { - const messages = connected ? Object.keys(store.session.message) : [] - const compactions = connected ? Object.keys(store.session.compaction) : [] - connected = true - refreshActive() - void Promise.allSettled([ - bootstrap(), - ...messages.map(result.session.message.refresh), - ...compactions.map(result.session.compaction.refresh), - ]) + void client.api.session + .active() + .then((active) => { + setStore( + "session", + "active", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), + ) + }) + .catch(() => undefined) + void client.api.session + .list({ + limit: 50, + order: "desc", + directory: defaultLocation().directory, + workspace: defaultLocation().workspaceID, + }) + .then((response) => { + setStore( + "session", + "info", + produce((draft) => { + for (const session of response.data) draft[session.id] = session + }), + ) + for (const session of response.data) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } + }) + .catch((error) => console.error("Failed to preload sessions", error)) return } handleEvent(details) diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 0714ffdb7e..0bde553381 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,13 +1,35 @@ import type { LocationRef } from "@opencode-ai/client" -import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js" +import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import { useClient } from "./client" +import { useData } from "./data" const context = createContext<{ current: Accessor - set: Setter + set: (location?: LocationRef) => void }>() export function LocationProvider(props: ParentProps) { - const [current, set] = createSignal() + const client = useClient() + const data = useData() + const [current, setCurrent] = createSignal() + + function sync(location?: LocationRef) { + if (!location) return + const defaultLocation = data.location.default() + const target = + location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID + ? undefined + : location + void data.location.sync(target).catch(() => undefined) + } + + function set(location?: LocationRef) { + setCurrent(location) + if (client.connection.status() === "connected") sync(location) + } + + onCleanup(client.event.on("server.connected", () => sync(current()))) + return {props.children} } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f8d3302461..6e865269ca 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -184,23 +184,22 @@ export function Session() { const rows = createSessionRows(() => route.sessionID) createEffect( - on(descendantSessionIDs, (sessionIDs) => { + on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { + if (status !== "connected") return void Promise.all( - sessionIDs.flatMap((sessionID) => [ - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - ]), + sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), ) }), ) createEffect(() => { + if (client.connection.status() !== "connected") return const sessionID = route.sessionID void (async () => { await Promise.all([ - data.session.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), + data.session.sync(sessionID), + data.session.permission.sync(sessionID), + data.session.form.sync(sessionID), ]) const info = data.session.get(sessionID) if (!info) { @@ -212,13 +211,6 @@ export function Session() { navigate({ type: "home" }) return } - void data.session.form.refresh("global", info.location).catch((error) => - toast.show({ - message: `Failed to refresh global forms: ${errorMessage(error)}`, - variant: "error", - duration: 5000, - }), - ) project.workspace.set(info.location.workspaceID) editor.reconnect(info.location.directory) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 3fb9323715..471b282097 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -2,6 +2,7 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/c import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useData } from "../../context/data" +import { useClient } from "../../context/client" export type PartRef = { messageID: string @@ -29,6 +30,7 @@ export type SessionRow = export function createSessionRows(sessionID: Accessor) { const data = useData() + const client = useClient() const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID @@ -42,9 +44,10 @@ export function createSessionRows(sessionID: Accessor) { rows.splice( position === -1 ? rows.length : position, 0, - ...data.session.compaction + ...data.session.pending .list(sessionID()) - .map((inputID): SessionRow => ({ type: "compaction-queued", inputID })), + .filter((item) => item.type === "compaction") + .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), ) return rows } @@ -67,10 +70,11 @@ export function createSessionRows(sessionID: Accessor) { }) createEffect( - on(sessionID, (id) => { + on([sessionID, () => client.connection.status()], ([id, status]) => { + if (status !== "connected") return setRows(reconcile(reduce())) - void data.session.compaction.refresh(id).catch(() => undefined) - void data.session.message.refresh(id).then( + void data.session.pending.sync(id).catch(() => undefined) + void data.session.message.sync(id).then( () => { if (sessionID() !== id) return setRows(reconcile(reduce())) @@ -89,7 +93,11 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on( - () => data.session.compaction.list(sessionID()).map((inputID) => inputID), + () => + data.session.pending + .list(sessionID()) + .filter((item) => item.type === "compaction") + .map((item) => item.id), () => setRows(reconcile(reduce())), ), ) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index ae1eabf683..444d0bb2ff 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -4,10 +4,11 @@ import { testRender } from "@opentui/solid" import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" -import { onMount } from "solid-js" +import { createEffect, onMount, type ParentProps } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { ClientProvider, useClient } from "../../../src/context/client" -import { DataProvider, useData } from "../../../src/context/data" +import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" +import { LocationProvider, useSetLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -32,6 +33,24 @@ function emitEvent(events: ReturnType, event: OpenCode events.emit({ ...event, location: { directory } }) } +function DataProvider(props: ParentProps) { + return ( + + + + {props.children} + + + ) +} + +function SyncLocation() { + const data = useData() + const setLocation = useSetLocation() + createEffect(() => setLocation(data.location.default())) + return null +} + function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } function durable( sessionID: string, @@ -64,16 +83,13 @@ test("bootstraps MCP data for the TUI location", async () => { try { await wait(() => requests.length === 2) - expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([ - process.cwd(), - process.cwd(), - ]) + expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([directory, directory]) } finally { app.renderer.destroy() } }) -test("refreshes MCP status when a connection settles during bootstrap", async () => { +test("syncs MCP status when a connection settles during bootstrap", async () => { const events = createEventStream() let mcpRequests = 0 let resolveModels!: (response: Response) => void @@ -190,9 +206,9 @@ test("refreshes resources into reactive getters", async () => { expect(data.session.get("ses_test")).toBeUndefined() expect(data.location.agent.list(location)).toBeUndefined() - await data.session.refresh("ses_test") - await data.session.message.refresh("ses_test") - await data.location.agent.refresh() + await data.session.sync("ses_test") + await data.session.message.sync("ses_test") + await data.location.agent.sync() expect(data.session.get("ses_test")?.title).toBe("Test session") expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"]) @@ -243,7 +259,7 @@ test("applies absolute usage events to session info", async () => { )) try { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) emitEvent(events, { id: "evt_usage_2", created: 2, @@ -328,7 +344,7 @@ test("truncates committed revert messages without changing lifetime usage", asyn )) try { - await data.session.refresh(sessionID) + await data.session.sync(sessionID) emitEvent(events, { id: "evt_revert_boundary_started", created: 1, @@ -467,7 +483,7 @@ test("updates session location when moved", async () => { try { await mounted - await data.session.refresh("ses_test") + await data.session.sync("ses_test") emitEvent(events, { id: "evt_moved_1", created: 1, @@ -525,7 +541,7 @@ test("restores running manual compaction before applying live deltas", async () )) try { - await data.session.message.refresh("session-compaction") + await data.session.message.sync("session-compaction") expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({ type: "compaction", status: "running", @@ -548,7 +564,7 @@ test("restores running manual compaction before applying live deltas", async () } }) -test("reconnects the event stream and bootstraps fresh data", async () => { +test("reconnects the event stream and resyncs active data", async () => { const events = createEventStream() const requests = { active: 0, event: 0, message: 0, model: 0 } let resolveActive!: (response: Response) => void @@ -621,7 +637,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { try { await wait(() => data.location.model.list()?.[0]?.id === "model-1") await wait(() => data.session.status("session-stale") === "running") - await data.session.message.refresh("session-stale") + await data.session.message.sync("session-stale") expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale") expect(client.connection.status()).toBe("connected") expect(client.connection.attempt()).toBe(0) @@ -633,6 +649,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000) resolveActive(json({ data: { "session-new": { type: "running" } } })) + void data.session.message.sync("session-stale") await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") @@ -664,8 +681,10 @@ test("completes exploration when a queued prompt is promoted", async () => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let rows!: ReturnType + let client!: ReturnType function Probe() { + client = useClient() rows = createSessionRows(() => sessionID) return } @@ -683,6 +702,7 @@ test("completes exploration when a queued prompt is promoted", async () => { )) try { + await wait(() => client.connection.status() === "connected") emitEvent(events, { id: "evt_step_started", created: 1, @@ -954,7 +974,7 @@ test("tracks session status from active sessions and execution events", async () try { await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") - await data.session.refresh("session-live") + await data.session.sync("session-live") settled = true emitEvent(events, { @@ -1021,7 +1041,7 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => data.session.status("session-live") === "idle") - await data.session.refresh("session-failed") + await data.session.sync("session-failed") emitEvent(events, { id: "evt_failed_execution_started", created: 0, @@ -1184,7 +1204,7 @@ test("tracks session status from active sessions and execution events", async () durable: durable("session-manual", 1), data: { sessionID: "session-manual", inputID: "message-compaction" }, }) - await wait(() => data.session.compaction.list("session-manual").includes("message-compaction")) + await wait(() => data.session.pending.list("session-manual").some((item) => item.id === "message-compaction")) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, @@ -1202,10 +1222,8 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-manual", "message-compaction") return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) - expect(data.session.compaction.list("session-manual")).toEqual([]) - const compactionRow = manualRows.find( - (row) => row.type === "message" && row.messageID === "message-compaction", - ) + expect(data.session.pending.list("session-manual")).toEqual([]) + const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction") emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, @@ -1247,9 +1265,7 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-live", "msg_compaction_started") return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" }) - const autoCompactionRow = rows.find( - (row) => row.type === "message" && row.messageID === "msg_compaction_started", - ) + const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started") emitEvent(events, { id: "evt_compaction_ended", @@ -1301,9 +1317,11 @@ test("restores queued compaction from durable pending input", async () => { }, events) let data!: ReturnType let rows!: ReturnType + let client!: ReturnType function Probe() { data = useData() + client = useClient() rows = createSessionRows(() => sessionID) return } @@ -1321,8 +1339,9 @@ test("restores queued compaction from durable pending input", async () => { )) try { - await wait(() => data.session.compaction.list(sessionID).length === 2) - expect(data.session.compaction.list(sessionID)).toEqual([ + await wait(() => client.connection.status() === "connected") + await wait(() => data.session.pending.list(sessionID).length === 2) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([ "message-compaction-queued", "message-compaction-later", ]) @@ -1359,8 +1378,8 @@ test("restores queued compaction from durable pending input", async () => { inputID: "message-compaction-queued", }, }) - await wait(() => data.session.compaction.list(sessionID).length === 1) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + await wait(() => data.session.pending.list(sessionID).length === 1) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) emitEvent(events, { id: "evt_compaction_ended", @@ -1369,15 +1388,12 @@ test("restores queued compaction from durable pending input", async () => { durable: durable(sessionID, 5), data: { sessionID, reason: "manual", text: "Summary", recent: "" }, }) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"]) pending = [] - emitEvent(events, { - id: "evt_reconnected", - type: "server.connected", - data: {}, - }) - await wait(() => data.session.compaction.list(sessionID).length === 0) + data.session.pending.invalidate(sessionID) + await data.session.pending.sync(sessionID) + await wait(() => data.session.pending.list(sessionID).length === 0) } finally { app.renderer.destroy() } @@ -1689,7 +1705,7 @@ test("keeps shell state scoped to location", async () => { try { await wait(() => data.shell.list().some((shell) => shell.id === "sh_default")) - await data.shell.refresh({ directory: other }) + await data.shell.sync({ directory: other }) expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"]) expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"]) @@ -1790,22 +1806,27 @@ test("adds and dismisses permission requests from live events", async () => { } }) -test("reconciles all pending permission requests when the event stream reconnects", async () => { +test("reconciles active session permissions when the event stream reconnects", async () => { const events = createEventStream() let requests = [ - { id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] }, - { id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] }, + { id: "per_old", sessionID: "ses_active", action: "read", resources: ["old.txt"] }, + { id: "per_keep", sessionID: "ses_active", action: "shell", resources: ["bun test"] }, ] let calls = 0 const fetch = createFetch((url) => { - if (url.pathname !== "/api/permission/request") return + if (url.pathname !== "/api/session/ses_active/permission") return calls++ - return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + return json({ data: requests }) }, events) let data!: ReturnType function Probe() { data = useData() + const client = useClient() + createEffect(() => { + if (client.connection.status() !== "connected") return + void data.session.permission.sync("ses_active") + }) return } @@ -1822,15 +1843,12 @@ test("reconciles all pending permission requests when the event stream reconnect )) try { - await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old") - expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep") + await wait(() => data.session.permission.list("ses_active")?.length === 2) - requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }] + requests = [{ id: "per_new", sessionID: "ses_active", action: "edit", resources: ["new.txt"] }] events.disconnect() - await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new") - expect(data.session.permission.list("ses_old")).toBeUndefined() - expect(data.session.permission.list("ses_keep")).toBeUndefined() + await wait(() => calls === 2 && data.session.permission.list("ses_active")?.[0]?.id === "per_new") } finally { app.renderer.destroy() } @@ -1903,7 +1921,7 @@ test("adds, dismisses, and refreshes form requests", async () => { }) await wait(() => data.session.form.list("ses_1")?.length === 0) - await data.session.form.refresh("ses_1") + await data.session.form.sync("ses_1") expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"]) } finally { app.renderer.destroy() @@ -1975,7 +1993,7 @@ test("tracks global forms by location", async () => { } }) -test("refreshes global forms for the requested location", async () => { +test("syncs global forms once for each requested location", async () => { const events = createEventStream() const requests: URL[] = [] const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" } @@ -2025,20 +2043,24 @@ test("refreshes global forms for the requested location", async () => { await wait(() => client.connection.status() === "connected" && requests.length > 0) requests.length = 0 - await data.session.form.refresh("global", { directory }) - await data.session.form.refresh("global", other) + await data.session.form.sync("global", { directory }) + await data.session.form.sync("global", other) - expect(requests).toHaveLength(2) - expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory) - expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID) + expect(requests).toHaveLength(1) + expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory) + expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID) expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"]) expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"]) + + data.session.form.invalidate("global", other) + await data.session.form.sync("global", other) + expect(requests).toHaveLength(2) } finally { app.renderer.destroy() } }) -test("refreshes global forms once per loaded location after reconnect", async () => { +test("resyncs global forms only for the active location after reconnect", async () => { const events = createEventStream() const requests: URL[] = [] const counts = new Map() @@ -2098,58 +2120,54 @@ test("refreshes global forms once per loaded location after reconnect", async () )) try { - await wait( - () => - data.session.form.list("global", home)?.[0]?.id === "frm_default_1" && - data.session.form.list("global", other)?.[0]?.id === "frm_other_1", - ) + await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_1") + await data.session.form.sync("global", other) + expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1") expect(requests).toHaveLength(2) requests.length = 0 events.disconnect() - await wait( - () => - data.session.form.list("global", home)?.[0]?.id === "frm_default_2" && - data.session.form.list("global", other)?.[0]?.id === "frm_other_2", - 4000, - ) - expect(requests).toHaveLength(2) + await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000) + expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1") + expect(requests).toHaveLength(1) expect( requests.map((url) => [ url.searchParams.get("location[directory]") ?? directory, url.searchParams.get("location[workspace]") ?? undefined, ]), - ).toEqual([ - [home.directory, undefined], - [other.directory, other.workspaceID], - ]) + ).toEqual([[home.directory, undefined]]) } finally { app.renderer.destroy() } }) -test("reconciles all pending form requests when the event stream reconnects", async () => { +test("reconciles active session forms when the event stream reconnects", async () => { const events = createEventStream() let requests = [ - { id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields }, + { id: "frm_old", sessionID: "ses_active", title: "Input requested", fields: formFields }, { id: "frm_keep", - sessionID: "ses_keep", + sessionID: "ses_active", title: "Input requested", fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }], }, ] let calls = 0 const fetch = createFetch((url) => { - if (url.pathname !== "/api/form/request") return + if (url.pathname !== "/api/session/ses_active/form") return calls++ - return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + return json({ data: requests }) }, events) let data!: ReturnType function Probe() { data = useData() + const client = useClient() + createEffect(() => { + if (client.connection.status() !== "connected") return + void data.session.form.sync("ses_active") + }) return } @@ -2166,15 +2184,12 @@ test("reconciles all pending form requests when the event stream reconnects", as )) try { - await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") - expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") + await wait(() => data.session.form.list("ses_active")?.length === 2) - requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: formFields }] + requests = [{ id: "frm_new", sessionID: "ses_active", title: "Input requested", fields: formFields }] events.disconnect() - await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") - expect(data.session.form.list("ses_old")).toBeUndefined() - expect(data.session.form.list("ses_keep")).toBeUndefined() + await wait(() => calls === 2 && data.session.form.list("ses_active")?.[0]?.id === "frm_new") } finally { app.renderer.destroy() } @@ -2421,7 +2436,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn ]) expect(sync.session.input.list(sessionID)).toEqual([messageID]) - await sync.session.message.refresh(sessionID) + await sync.session.message.sync(sessionID) expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() emitEvent(events, { @@ -2538,8 +2553,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) { async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") - return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) + if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2569,13 +2583,13 @@ async function mountData(parents: Record, costs: Record { const { data, app } = await mountData({ child: "root" }) try { - await data.session.refresh("child") + await data.session.sync("child") // Parent info is absent, so the missing parent is the furthest-known ancestor. expect(data.session.root("child")).toBe("root") expect(data.session.family("child")).toEqual(["child"]) expect(data.session.family("root")).toEqual(["child"]) - await data.session.refresh("root") + await data.session.sync("root") expect(data.session.root("root")).toBe("root") // The tentative root entry folds into the now-known root's family. expect(data.session.family("child")).toEqual(["child", "root"]) @@ -2588,17 +2602,17 @@ test("groups an orphan child under its missing parent until the root arrives", a test("indexes arbitrarily deep nesting under a single root", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try { - await data.session.refresh("grandchild") + await data.session.sync("grandchild") expect(data.session.root("grandchild")).toBe("child") expect(data.session.family("grandchild")).toEqual(["grandchild"]) - await data.session.refresh("child") + await data.session.sync("child") // grandchild's tentative family (keyed by the missing "child") merges up // toward the still-missing "root". expect(data.session.root("child")).toBe("root") expect(data.session.family("grandchild")).toEqual(["grandchild", "child"]) - await data.session.refresh("root") + await data.session.sync("root") expect(data.session.root("grandchild")).toBe("root") expect(data.session.root("child")).toBe("root") expect(data.session.family("root")).toEqual(["grandchild", "child", "root"]) @@ -2608,14 +2622,11 @@ test("indexes arbitrarily deep nesting under a single root", async () => { }) test("totals family cost for roots and keeps subagent cost scoped", async () => { - const { data, app } = await mountData( - { grandchild: "child", child: "root" }, - { root: 1, child: 2, grandchild: 3 }, - ) + const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 }) try { - await data.session.refresh("grandchild") - await data.session.refresh("child") - await data.session.refresh("root") + await data.session.sync("grandchild") + await data.session.sync("child") + await data.session.sync("root") expect(data.session.cost("root")).toBe(6) expect(data.session.cost("child")).toBe(2) @@ -2628,15 +2639,15 @@ test("totals family cost for roots and keeps subagent cost scoped", async () => test("re-registering an existing session is idempotent", async () => { const { data, app } = await mountData({ grandchild: "child", child: "root" }) try { - await data.session.refresh("grandchild") - await data.session.refresh("child") - await data.session.refresh("root") + await data.session.sync("grandchild") + await data.session.sync("child") + await data.session.sync("root") const before = data.session.family("root") expect(before).toEqual(["grandchild", "child", "root"]) - await data.session.refresh("child") - await data.session.refresh("root") - await data.session.refresh("grandchild") + await data.session.sync("child") + await data.session.sync("root") + await data.session.sync("grandchild") expect(data.session.family("root")).toEqual(before) expect(data.session.family("root")).toHaveLength(3) } finally { @@ -2647,8 +2658,8 @@ test("re-registering an existing session is idempotent", async () => { test("stops at the last non-repeating ancestor on a parent cycle", async () => { const { data, app } = await mountData({ x: "y", y: "x" }) try { - await data.session.refresh("x") - await data.session.refresh("y") + await data.session.sync("x") + await data.session.sync("y") // Does not hang; walking up from "y" stops before re-entering "x". expect(data.session.root("y")).toBe("x") expect(data.session.family("y")).toEqual(["x", "y"])