From 18c25ae406b00c434cd6f2f721c94ed5db026606 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:28:47 +1000 Subject: [PATCH] refactor(app): use one server event model (#43168) --- packages/app/src/context/file.tsx | 4 +- packages/app/src/context/file/watcher.test.ts | 178 +++++++----------- packages/app/src/context/file/watcher.ts | 32 +--- .../context/global-sync/event-reducer.test.ts | 61 ------ .../src/context/global-sync/event-reducer.ts | 63 ------- packages/app/src/context/global.tsx | 5 +- packages/app/src/context/notification.tsx | 25 ++- packages/app/src/context/permission.tsx | 14 +- packages/app/src/context/server-sdk.test.ts | 105 ++++++++--- packages/app/src/context/server-sdk.tsx | 94 ++++----- packages/app/src/context/server-sync.tsx | 51 ++--- packages/app/src/context/terminal.tsx | 4 +- packages/app/src/pages/session.tsx | 2 +- .../pages/session/usage-exceeded-dialogs.tsx | 8 +- packages/app/src/types.ts | 17 +- 15 files changed, 238 insertions(+), 425 deletions(-) delete mode 100644 packages/app/src/context/global-sync/event-reducer.test.ts delete mode 100644 packages/app/src/context/global-sync/event-reducer.ts diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 8f8d3a149f2..a9b083e2dfb 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -225,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }, ) - const stop = sdk().event.listen((e) => { - invalidateFromWatcher(e.details, { + const stop = sdk().event.on("filesystem.changed", (event) => { + invalidateFromWatcher(event, { normalize: path.normalize, hasFile: (file) => Boolean(store.file[file]), isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index dbe745ff7d9..4a43983aa75 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -1,27 +1,28 @@ import { describe, expect, test } from "bun:test" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import { invalidateFromWatcher } from "./watcher" +type FilesystemEvent = Extract + +const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({ + id: `evt_${file}`, + created: 1, + type: "filesystem.changed", + data: { file, event }, +}) + describe("file watcher invalidation", () => { test("reloads open files and refreshes loaded parent on add", () => { const loads: string[] = [] const refresh: string[] = [] - invalidateFromWatcher( - { - type: "filesystem.changed", - properties: { - file: "src/new.ts", - event: "add", - }, - }, - { - normalize: (input) => input, - hasFile: (path) => path === "src/new.ts", - loadFile: (path) => loads.push(path), - node: () => undefined, - isDirLoaded: (path) => path === "src", - refreshDir: (path) => refresh.push(path), - }, - ) + invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }) expect(loads).toEqual(["src/new.ts"]) expect(refresh).toEqual(["src"]) @@ -30,30 +31,21 @@ describe("file watcher invalidation", () => { test("reloads files that are open in tabs", () => { const loads: string[] = [] - invalidateFromWatcher( - { - type: "filesystem.changed", - properties: { - file: "src/open.ts", - event: "change", - }, - }, - { - normalize: (input) => input, - hasFile: () => false, - isOpen: (path) => path === "src/open.ts", - loadFile: (path) => loads.push(path), - node: () => ({ - path: "src/open.ts", - type: "file", - name: "open.ts", - absolute: "/repo/src/open.ts", - ignored: false, - }), - isDirLoaded: () => false, - refreshDir: () => {}, - }, - ) + invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), { + normalize: (input) => input, + hasFile: () => false, + isOpen: (path) => path === "src/open.ts", + loadFile: (path) => loads.push(path), + node: () => ({ + path: "src/open.ts", + type: "file", + name: "open.ts", + absolute: "/repo/src/open.ts", + ignored: false, + }), + isDirLoaded: () => false, + refreshDir: () => {}, + }) expect(loads).toEqual(["src/open.ts"]) }) @@ -61,47 +53,29 @@ describe("file watcher invalidation", () => { test("refreshes only changed loaded directory nodes", () => { const refresh: string[] = [] - invalidateFromWatcher( - { - type: "filesystem.changed", - properties: { - file: "src", - event: "change", - }, - }, - { - normalize: (input) => input, - hasFile: () => false, - loadFile: () => {}, - node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), - isDirLoaded: (path) => path === "src", - refreshDir: (path) => refresh.push(path), - }, - ) + invalidateFromWatcher(filesystemEvent("src", "change"), { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }) - invalidateFromWatcher( - { - type: "filesystem.changed", - properties: { - file: "src/file.ts", - event: "change", - }, - }, - { - normalize: (input) => input, - hasFile: () => false, - loadFile: () => {}, - node: () => ({ - path: "src/file.ts", - type: "file", - name: "file.ts", - absolute: "/repo/src/file.ts", - ignored: false, - }), - isDirLoaded: () => true, - refreshDir: (path) => refresh.push(path), - }, - ) + invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ + path: "src/file.ts", + type: "file", + name: "file.ts", + absolute: "/repo/src/file.ts", + ignored: false, + }), + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }) expect(refresh).toEqual(["src"]) }) @@ -109,40 +83,16 @@ describe("file watcher invalidation", () => { test("ignores invalid or git watcher updates", () => { const refresh: string[] = [] - invalidateFromWatcher( - { - type: "filesystem.changed", - properties: { - file: ".git/index.lock", - event: "change", - }, + invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") }, - { - normalize: (input) => input, - hasFile: () => true, - loadFile: () => { - throw new Error("should not load") - }, - node: () => undefined, - isDirLoaded: () => true, - refreshDir: (path) => refresh.push(path), - }, - ) - - invalidateFromWatcher( - { - type: "project.updated", - properties: {}, - }, - { - normalize: (input) => input, - hasFile: () => false, - loadFile: () => {}, - node: () => undefined, - isDirLoaded: () => true, - refreshDir: (path) => refresh.push(path), - }, - ) + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }) expect(refresh).toEqual([]) }) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index 3b58d31b839..16331400e1f 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,9 +1,7 @@ import type { FileNode } from "@/types" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" -type WatcherEvent = { - type: string - properties: unknown -} +type WatcherEvent = Extract type WatcherOps = { normalize: (input: string) => string @@ -16,15 +14,7 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "filesystem.changed") return - const props = - typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined - const rawPath = typeof props?.file === "string" ? props.file : undefined - const kind = typeof props?.event === "string" ? props.event : undefined - if (!rawPath) return - if (!kind) return - - const path = ops.normalize(rawPath) + const path = ops.normalize(event.data.file) if (!path) return if (path.startsWith(".git/")) return @@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { ops.loadFile(path) } - if (kind === "change") { - const dir = (() => { - if (path === "") return "" - const node = ops.node(path) - if (node?.type !== "directory") return - return path - })() - if (dir === undefined) return - if (!ops.isDirLoaded(dir)) return - ops.refreshDir(dir) + if (event.data.event === "change") { + if (ops.node(path)?.type !== "directory") return + if (!ops.isDirLoaded(path)) return + ops.refreshDir(path) return } - if (kind !== "add" && kind !== "unlink") return - const parent = path.split("/").slice(0, -1).join("/") if (!ops.isDirLoaded(parent)) return diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts deleted file mode 100644 index 5ea4c4ea8af..00000000000 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createStore } from "solid-js/store" -import type { Project } from "@/types" -import type { State } from "./types" -import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" - -describe("applyGlobalEvent", () => { - test("upserts project.updated in sorted position", () => { - const projects = [{ id: "b", worktree: "/b" }] as Project[] - let next = projects - applyGlobalEvent({ - event: { type: "project.updated", properties: { id: "a", worktree: "/a" } }, - project: projects, - setGlobalProject: (value) => { - next = typeof value === "function" ? value(next) : value - }, - refresh() {}, - }) - expect(next.map((project) => project.id)).toEqual(["a", "b"]) - }) - - test("refreshes on global disposal", () => { - let refreshed = false - applyGlobalEvent({ - event: { type: "global.disposed" }, - project: [], - setGlobalProject() {}, - refresh: () => (refreshed = true), - }) - expect(refreshed).toBe(true) - }) -}) - -describe("applyDirectoryEvent", () => { - test("updates vcs and routes refresh events", () => { - const [store, setStore] = createStore({ vcs: { branch: "old" } } as State) - const pushed: string[] = [] - let lsp = 0 - let references = 0 - const apply = (type: string, properties?: unknown) => - applyDirectoryEvent({ - event: { type, properties }, - store, - setStore, - directory: "/repo", - push: (directory) => pushed.push(directory), - loadLsp: () => lsp++, - loadReferences: () => references++, - }) - - apply("vcs.branch.updated", { branch: "main" }) - apply("server.instance.disposed") - apply("lsp.updated") - apply("reference.updated") - - expect(store.vcs?.branch).toBe("main") - expect(pushed).toEqual(["/repo"]) - expect(lsp).toBe(1) - expect(references).toBe(1) - }) -}) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts deleted file mode 100644 index 623a1f32cd0..00000000000 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Binary } from "@opencode-ai/core/util/binary" -import { produce, type SetStoreFunction, type Store } from "solid-js/store" -import type { Project } from "@/types" -import type { State, VcsCache } from "./types" - -export function applyGlobalEvent(input: { - event: { type: string; properties?: unknown } - project: Project[] - setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => 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, (project) => project.id) - if (result.found) { - input.setGlobalProject( - produce((draft) => { - draft[result.index] = { ...draft[result.index], ...properties } - }), - ) - return - } - input.setGlobalProject( - produce((draft) => { - draft.splice(result.index, 0, properties) - }), - ) -} - -export function applyDirectoryEvent(input: { - event: { type: string; properties?: unknown } - store: Store - setStore: SetStoreFunction - push: (directory: string) => void - directory: string - loadLsp: () => void - loadReferences?: () => void - vcsCache?: VcsCache -}) { - switch (input.event.type) { - case "server.instance.disposed": - input.push(input.directory) - break - case "vcs.branch.updated": { - const properties = input.event.properties as { branch?: string } - if (input.store.vcs?.branch === properties.branch) break - const next = { ...input.store.vcs, branch: properties.branch } - input.setStore("vcs", next) - input.vcsCache?.setStore("value", next) - break - } - case "lsp.updated": - input.loadLsp() - break - case "reference.updated": - input.loadReferences?.() - break - } -} diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 3d2d96448ce..1da9bbbb5ee 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -102,7 +102,10 @@ function createServerController( const sdk = createServerSdkContext(conn, scope) const data = createData({ api: () => sdk.api, - event: sdk.event, + event: { + on: sdk.event.on, + listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })), + }, connection: sdk.connection, directory: "", }) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 3fe97977cde..6db7369b596 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -3,12 +3,12 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { Data } from "@opencode-ai/client/solid" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" -import type { EventSessionError } from "@/types" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" import { useGlobal } from "./global" @@ -32,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & { type ErrorNotification = NotificationBase & { type: "error" - error: EventSessionError["properties"]["error"] + error: Extract["data"]["error"] } export type Notification = TurnCompleteNotification | ErrorNotification @@ -216,8 +216,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat dispatchEvent(new PopStateEvent("popstate")) } - const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => { - const sessionID = event.properties.sessionID + const handleSessionIdle = (directory: string, sessionID: string, time: number) => { void lookup(sessionID).then((session) => { if (meta.disposed) return if (!session) return @@ -246,10 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat const handleSessionError = ( directory: string, - event: { properties: EventSessionError["properties"] }, + sessionID: string, + error: ErrorNotification["error"], time: number, ) => { - const sessionID = event.properties.sessionID void lookup(sessionID).then((session) => { if (meta.disposed) return if (session?.parentID) return @@ -258,27 +257,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat void playSoundById(settings.sounds.errors()) } - const error = event.properties.error append({ directory, time, viewed: viewedInCurrentSession(sessionID), type: "error", - session: sessionID ?? "global", + session: sessionID, error, }) const description = session?.title ?? (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) - const href = sessionHref(input.key, sessionID ?? "global") + const href = sessionHref(input.key, sessionID) if (settings.notifications.errors()) { void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href)) } }) } - const unsub = input.sdk.eventByDir.listen((e) => { - const event = e.details + const unsub = input.sdk.event.listen((event) => { if ( event.type !== "session.execution.succeeded" && event.type !== "session.execution.interrupted" && @@ -286,14 +283,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat ) return - const directory = event.current?.location?.directory + const directory = event.location?.directory if (!directory) return const time = Date.now() if (event.type === "session.execution.failed") { - handleSessionError(directory, event, time) + handleSessionError(directory, event.data.sessionID, event.data.error, time) return } - handleSessionIdle(directory, event, time) + handleSessionIdle(directory, event.data.sessionID, time) }) onCleanup(() => { meta.disposed = true diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index ebbcc185e4e..0107ccdb676 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) { return Object.values(config).some(isNonAllowRule) } -type PermissionEvent = Parameters[0]>[0] - export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) { const [store, setStore, _, ready] = persisted( { @@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve return next } - const handlePermission = (e: PermissionEvent) => { - const event = e.details - if (event?.type !== "permission.asked") return - void respondPending(event.properties, event.current?.location?.directory) - } - - const unsubscribe = input.sdk.eventByDir.listen((event) => { + const unsubscribe = input.sdk.event.on("permission.asked", (event) => { if (ready()) { - handlePermission(event) + void respondPending(event.data, event.location?.directory) return } void ready.promise?.then(() => { if (meta.disposed) return - handlePermission(event) + void respondPending(event.data, event.location?.directory) }) }) onCleanup(() => { diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 106cf91e2ab..bb99d77927a 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,33 +1,86 @@ import { describe, expect, test } from "bun:test" import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import { adaptServerEvent } from "./server-sdk" +import { createRoot } from "solid-js" +import { createOpenCodeEventSource } from "./server-sdk" -describe("adaptServerEvent", () => { - test("preserves current permission requests", () => { - const current = { - id: "evt_1", - created: 1, - type: "permission.asked", - data: { - id: "perm_1", - sessionID: "ses_1", - action: "read", - resources: ["src/**"], - source: { type: "tool", messageID: "msg_1", id: "call_1" }, - }, - } as OpenCodeEvent +const permission = { + id: "evt_permission", + created: 1, + type: "permission.asked", + location: { directory: "/repo", workspaceID: "workspace_1" }, + data: { + id: "perm_1", + sessionID: "ses_1", + action: "read", + resources: ["src/**"], + source: { type: "tool", messageID: "msg_1", id: "call_1" }, + }, +} satisfies Extract - expect(adaptServerEvent(current)).toMatchObject({ - id: "evt_1", - type: "permission.asked", - properties: { - id: "perm_1", - sessionID: "ses_1", - action: "read", - resources: ["src/**"], - source: { type: "tool", messageID: "msg_1", id: "call_1" }, - }, - current, +function setup() { + return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose })) +} + +describe("server event stream", () => { + test("publishes the original current event with exact data", () => { + const server = setup() + const received: OpenCodeEvent[] = [] + let requestID: string | undefined + + server.event.on("permission.asked", (event) => { + requestID = event.data.id }) + server.event.listen((event) => received.push(event)) + server.publish(permission) + + expect(requestID).toBe("perm_1") + expect(received).toEqual([permission]) + expect(received[0]).toBe(permission) + server.dispose() + }) + + test("filters locations without changing workspace identity", () => { + const server = setup() + const repo: OpenCodeEvent[] = [] + const other: OpenCodeEvent[] = [] + const all: OpenCodeEvent[] = [] + let workspaceID: string | undefined + const global = { + id: "evt_connected", + type: "server.connected", + data: {}, + } satisfies Extract + + const repoEvents = server.event.location("/repo") + repoEvents.on("permission.asked", (event) => { + workspaceID = event.location?.workspaceID + }) + repoEvents.listen((event) => repo.push(event)) + server.event.location("/other").listen((event) => other.push(event)) + server.event.listen((event) => all.push(event)) + server.publish(permission) + server.publish(global) + + expect(repo).toEqual([permission]) + expect(workspaceID).toBe("workspace_1") + expect(other).toEqual([]) + expect(all).toEqual([permission, global]) + server.dispose() + }) + + test("isolates servers and clears subscriptions with their owner", () => { + const first = setup() + const second = setup() + const received = { first: 0, second: 0 } + + first.event.listen(() => received.first++) + second.event.listen(() => received.second++) + first.publish(permission) + first.dispose() + first.publish(permission) + second.publish(permission) + + expect(received).toEqual({ first: 1, second: 1 }) + second.dispose() }) }) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index b075fd5a36d..8d842cd913d 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,6 +1,5 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise" import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid" -import type { Event } from "@/types" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { type Accessor, onCleanup } from "solid-js" import { createApiForServer, type ServerApi } from "@/utils/server" @@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount" import { ServerScope } from "@/utils/server-scope" import { useServer } from "./server" -export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } +type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract } -export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { - return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +export type OpenCodeEventStream = { + on(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction + listen(handler: (event: OpenCodeEvent) => void): VoidFunction +} + +type OpenCodeEventSource = OpenCodeEventStream & { + location(directory: string): OpenCodeEventStream +} + +export function createOpenCodeEventSource() { + const emitter = createGlobalEmitter() + + function stream(directory?: string): OpenCodeEventStream { + return { + on(type, handler) { + return emitter.on(type, (event) => { + if (directory !== undefined && event.location?.directory !== directory) return + handler(event) + }) + }, + listen(handler) { + return emitter.listen((event) => { + if (directory !== undefined && event.details.location?.directory !== directory) return + handler(event.details) + }) + }, + } + } + + const event: OpenCodeEventSource = { + ...stream(), + location: (directory) => stream(directory), + } + + onCleanup(() => emitter.clear()) + + return { + event, + publish(event: OpenCodeEvent) { + emitter.emit(event.type, event) + }, + } } -type ServerEventEmitter = ReturnType> -type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract } -type CurrentEventEmitter = ReturnType> export type ServerConnectionStatus = ClientConnectionStatus type ServerSDKBase = { server: ServerConnection.Any @@ -30,28 +66,19 @@ type ServerSDKBase = { attempt: Accessor error: Accessor } - eventByDir: { - on: ServerEventEmitter["on"] - listen: ServerEventEmitter["listen"] - } - event: { - on: CurrentEventEmitter["on"] - listen: CurrentEventEmitter["listen"] - } + event: OpenCodeEventSource } function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const api = createApiForServer({ server: server.http, fetch: platform.fetch }) - const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>() - const emitter = createGlobalEmitter() + const events = createOpenCodeEventSource() const connection = createClientConnection(api, { flushInterval: 16, pageLifecycle: true, onEvent(event) { - emitter.emit(event.type, event) - dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event)) + events.publish(event) }, log: { info(message, data) { @@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS }, }) - onCleanup(() => { - dirEmitter.clear() - emitter.clear() - }) - return { server, scope, url: server.http.url, api, connection, - eventByDir: { - on: dirEmitter.on.bind(dirEmitter), - listen: dirEmitter.listen.bind(dirEmitter), - }, - event: { - on: emitter.on.bind(emitter), - listen: emitter.listen.bind(emitter), - }, + event: events.event, } } @@ -99,25 +114,14 @@ export const useServerSDK = () => { return server.ctx.sdk } -type SDKEventMap = { - [key in Event["type"]]: Extract -} - export type LocationContext = { directory: string - event: ReturnType> + event: OpenCodeEventStream } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext { - const emitter = createGlobalEmitter() - - const unsub = serverSDK.eventByDir.on(directory, (event) => { - emitter.emit(event.type, event) - }) - onCleanup(unsub) - return { directory, - event: emitter, + event: serverSDK.event.location(directory), } } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 8289c7ee5cc..473631f9f6b 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -7,7 +7,6 @@ import { useLanguage } from "@/context/language" import { type ServerSDK } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import type { ProjectMeta } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" @@ -219,51 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { return promise } - const unsub = serverSDK.eventByDir.listen((e) => { - const directory = e.name - const key = directoryKey(directory) - const event = e.details - const eventType: string = event.type - connection.handleEvent({ type: eventType }) + const unsub = serverSDK.event.listen((event) => { + connection.handleEvent({ type: event.type }) - if (directory === "global") { - applyGlobalEvent({ - event, - project: globalStore.project, - refresh: () => void bootstrap.refetch(), - setGlobalProject: setProjects, - }) - if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated") + if (!event.location) { + if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated") bootstrap.refetch() - if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push) return } - const existing = children.children[key] - if (!existing) return + const directory = event.location.directory + const key = directoryKey(directory) + if (!children.children[key]) return children.mark(key) - if (eventType === "config.updated" || eventType === "agent.updated") queue.push(key) - const [store, setStore] = existing - if (eventType === "worktree.updated") void bootstrap.refetch() - if (eventType !== "vcs.branch.updated") - applyDirectoryEvent({ - event, - directory, - store, - setStore, - push: (directory) => { - if (children.active(directory)) queue.push(directory) - }, - vcsCache: children.vcsCache.get(key), - loadLsp: () => { - if (!children.active(key)) return - void queryClient.fetchQuery(queryOptionsApi.lsp(key)) - }, - loadReferences: () => { - if (!children.active(key)) return - void data.location.reference.sync({ directory: key }).catch(() => undefined) - }, - }) + if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key) + if (event.type === "worktree.updated") void bootstrap.refetch() + if (event.type === "reference.updated" && children.active(key)) + void data.location.reference.sync({ directory: key }).catch(() => undefined) }) onCleanup(unsub) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index fc53d2f546f..cd707cb44a4 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -215,8 +215,8 @@ function createWorkspaceTerminalSession( }) } - const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { - removeExited(event.properties.id) + const unsub = sdk.event.on("pty.exited", (event) => { + removeExited(event.data.id) }) onCleanup(unsub) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 08bd19434dc..fa73fbcf20d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -616,7 +616,7 @@ export default function Page() { }, 100) onCleanup( sdk().event.listen((event) => { - if (event.details.type === "filesystem.changed") refreshVcs() + if (event.type === "filesystem.changed") refreshVcs() }), ) createEffect( diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index 62682e866ff..472385f72fd 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -52,13 +52,13 @@ export function useUsageExceededDialogs() { onCleanup( sdk().event.on("session.status", (evt) => { - if (evt.properties.sessionID !== params.id) return - if (evt.properties.status.type !== "retry") return - const { action } = evt.properties.status + if (evt.data.sessionID !== params.id) return + if (evt.data.status.type !== "retry") return + const { action } = evt.data.status if (!action) return if (dialog.active) return - const keys = goUpsellKeys(evt.properties.status) + const keys = goUpsellKeys(evt.data.status) if (!keys) return const seen = goUpsellState[keys.lastSeenAt] diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 4141f094855..8cbd1e6b317 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -1,9 +1,4 @@ -import type { - EventSubscribeOutput, - FileDiffInfo, - ProjectListOutput, - WorktreeDirectory, -} from "@opencode-ai/client/promise" +import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export type Project = Omit & { @@ -11,16 +6,6 @@ export type Project = Omit & { worktrees: WorktreeDirectory[] } -type CurrentEvent = EventSubscribeOutput extends infer Item - ? Item extends { type: infer Type extends string; data: infer Data } - ? { type: Type; properties: Data } - : never - : never - -export type Event = CurrentEvent - -export type EventSessionError = Extract - type MessageError = | { name: "ProviderAuthError"; data: { providerID: string; message: string } } | { name: "UnknownError"; data: { message: string; ref?: string } }