mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
2 Commits
beta
...
shared-data-impl
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbb043d4a | |||
| a9c3d484ba |
@@ -188,12 +188,15 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.101",
|
"effect": "4.0.0-beta.101",
|
||||||
|
"solid-js": ">=1.9.0",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"effect",
|
"effect",
|
||||||
|
"solid-js",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
|||||||
import type { TextPart as SDKTextPart } from "@/types"
|
import type { TextPart as SDKTextPart } from "@/types"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
interface ForkableMessage {
|
interface ForkableMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,6 +30,7 @@ export const DialogFork: Component = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -73,7 +77,7 @@ export const DialogFork: Component = () => {
|
|||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.id}`)
|
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, For, createMemo, createResource } from "solid-js"
|
import { Component, For, createEffect, createMemo } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -22,6 +23,7 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
const configMcp = serverSync.data.config.mcp ?? {}
|
const configMcp = serverSync.data.config.mcp ?? {}
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
return Object.entries(configMcp).map(([name, config]) => ({
|
||||||
@@ -47,9 +49,11 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
createEffect(() => {
|
||||||
initialValue: [],
|
if (serverSdk.connection.status() !== "connected") return
|
||||||
|
void data.location.skill.sync().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
const skills = () => data.location.skill.list() ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
import type { CatalogApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import {
|
import { loadPathQuery, loadProjectsQuery, loadProvidersQuery } from "./bootstrap"
|
||||||
loadAgentsQuery,
|
|
||||||
loadCommands,
|
|
||||||
loadPathQuery,
|
|
||||||
loadProjectsQuery,
|
|
||||||
loadProvidersQuery,
|
|
||||||
loadReferencesQuery,
|
|
||||||
} from "./bootstrap"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
@@ -73,39 +66,6 @@ describe("query keys", () => {
|
|||||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads agents from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return { location: {}, data: [] }
|
|
||||||
},
|
|
||||||
} as unknown as AgentApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads commands from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: {},
|
|
||||||
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as unknown as CommandApi
|
|
||||||
|
|
||||||
const result = await loadCommands("/repo", api)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const projects = {
|
const projects = {
|
||||||
@@ -161,18 +121,4 @@ describe("query keys", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads references from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
|
||||||
},
|
|
||||||
} as unknown as ReferenceApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
|
||||||
AgentListOutput,
|
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandInfo,
|
|
||||||
CommandListInput,
|
|
||||||
CommandListOutput,
|
|
||||||
IntegrationListInput,
|
|
||||||
IntegrationListOutput,
|
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
ProjectCurrentInput,
|
ProjectCurrentInput,
|
||||||
ProjectCurrentOutput,
|
ProjectCurrentOutput,
|
||||||
ProjectListOutput,
|
ProjectListOutput,
|
||||||
ReferenceListInput,
|
|
||||||
ReferenceListOutput,
|
|
||||||
ReferenceInfo,
|
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
@@ -28,10 +18,9 @@ import { batch } from "solid-js"
|
|||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
import { cmp, directoryKey, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
@@ -110,10 +99,8 @@ type ProjectApi = {
|
|||||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
|
||||||
type PermissionApi = ServerApi["permission"]
|
type PermissionApi = ServerApi["permission"]
|
||||||
type QuestionApi = ServerApi["question"]
|
type QuestionApi = ServerApi["question"]
|
||||||
type VcsApi = ServerApi["vcs"]
|
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -241,38 +228,6 @@ export const loadProvidersQuery = (scope: ServerScope, directory: string | null,
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
type AgentListApi = {
|
|
||||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandListApi = {
|
|
||||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type IntegrationListApi = {
|
|
||||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReferenceListApi = {
|
|
||||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "agents"],
|
|
||||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "integrations"] as const,
|
|
||||||
queryFn: () =>
|
|
||||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
|
||||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
@@ -286,28 +241,15 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
|
||||||
queryOptions<ReferenceInfo[]>({
|
|
||||||
queryKey: [scope, directory, "references"] as const,
|
|
||||||
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
|
||||||
placeholderData: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
api: CatalogApi & {
|
api: CatalogApi & {
|
||||||
readonly agent: AgentListApi
|
|
||||||
readonly command: CommandListApi
|
|
||||||
readonly mcp: McpApi
|
|
||||||
readonly permission: PermissionApi
|
readonly permission: PermissionApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly question: QuestionApi
|
readonly question: QuestionApi
|
||||||
readonly reference: ReferenceListApi
|
|
||||||
readonly session: SessionApi
|
readonly session: SessionApi
|
||||||
readonly vcs: VcsApi
|
|
||||||
readonly location: LocationApi
|
|
||||||
}
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
@@ -324,9 +266,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
|
||||||
if (seededProject) input.setStore("project", seededProject)
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
if (seededPath) input.setStore("path", seededPath)
|
|
||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||||
}
|
}
|
||||||
@@ -337,29 +277,11 @@ export async function bootstrapDirectory(input: {
|
|||||||
providerRev.set(revKey, rev)
|
providerRev.set(revKey, rev)
|
||||||
const slow = [
|
const slow = [
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
|
||||||
input.queryClient
|
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
|
||||||
.then((data) => input.setStore("agent", data)),
|
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
input.setStore("project", project.id),
|
input.setStore("project", project.id),
|
||||||
)),
|
)),
|
||||||
!seededPath &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient
|
|
||||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
|
||||||
.then((data) => {
|
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
|
||||||
if (next) input.setStore("project", next)
|
|
||||||
})),
|
|
||||||
input.mcp &&
|
|
||||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
|
||||||
() =>
|
|
||||||
input.queryClient.fetchQuery(
|
|
||||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
|
||||||
),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.api.permission.request
|
input.api.permission.request
|
||||||
@@ -429,11 +351,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp &&
|
|
||||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
|
||||||
input.mcp &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/con
|
|||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
@@ -18,6 +19,20 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
|||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
const path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||||
|
const data = {
|
||||||
|
location: {
|
||||||
|
info: () => undefined,
|
||||||
|
agent: { list: () => undefined },
|
||||||
|
command: { list: () => undefined },
|
||||||
|
reference: { list: () => undefined },
|
||||||
|
mcp: {
|
||||||
|
server: { list: () => undefined },
|
||||||
|
resource: { list: () => undefined },
|
||||||
|
},
|
||||||
|
vcs: { info: () => undefined },
|
||||||
|
},
|
||||||
|
} as unknown as Data
|
||||||
|
|
||||||
const queryOptionsApi = {
|
const queryOptionsApi = {
|
||||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||||
@@ -100,7 +115,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
@@ -134,7 +150,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,7 +184,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,7 +216,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +233,7 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("enables MCP only when requested for the directory", () => {
|
test("syncs MCP only when requested for the directory", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
const offset = querySingles.length
|
const offset = querySingles.length
|
||||||
const mcpLoads: string[] = []
|
const mcpLoads: string[] = []
|
||||||
@@ -234,30 +253,21 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!manager) throw new Error("manager required")
|
if (!manager) throw new Error("manager required")
|
||||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||||
expect(querySingles.length - offset).toBe(6)
|
expect(querySingles.length - offset).toBe(2)
|
||||||
const query = querySingles[offset + 1]
|
|
||||||
const resourceQuery = querySingles[offset + 2]
|
|
||||||
if (!query) throw new Error("query required")
|
|
||||||
if (!resourceQuery) throw new Error("resource query required")
|
|
||||||
expect(query().enabled).toBe(false)
|
|
||||||
expect(resourceQuery().enabled).toBe(false)
|
|
||||||
|
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
manager.child("/project", { bootstrap: false, mcp: true })
|
manager.child("/project", { bootstrap: false, mcp: true })
|
||||||
expect(query().enabled).toBe(true)
|
|
||||||
expect(resourceQuery().enabled).toBe(true)
|
|
||||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
|
||||||
expect(mcpLoads).toEqual(["/project"])
|
expect(mcpLoads).toEqual(["/project"])
|
||||||
|
|
||||||
manager.disableMcp("/project")
|
manager.disableMcp("/project")
|
||||||
expect(query().enabled).toBe(false)
|
|
||||||
expect(manager.mcp("/project")).toBe(false)
|
expect(manager.mcp("/project")).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -284,7 +294,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,11 +304,9 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/project", { bootstrap: false })
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
expect(queries).toHaveLength(6)
|
expect(queries).toHaveLength(2)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
expect(queries[3]?.().enabled).toBe(false)
|
expect(queries[1]?.().enabled).toBe(false)
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
|
||||||
expect(queries[5]?.().enabled).toBe(false)
|
|
||||||
expect(store.path.directory).toBe("/project")
|
expect(store.path.directory).toBe("/project")
|
||||||
expect(store.provider_ready).toBe(false)
|
expect(store.provider_ready).toBe(false)
|
||||||
expect(store.lsp_ready).toBe(false)
|
expect(store.lsp_ready).toBe(false)
|
||||||
@@ -305,9 +314,7 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
manager.child("/project")
|
manager.child("/project")
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[3]?.().enabled).toBe(true)
|
expect(queries[1]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
|
||||||
expect(queries[5]?.().enabled).toBe(true)
|
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
manager.child("/project", { bootstrap: false })
|
manager.child("/project", { bootstrap: false })
|
||||||
@@ -334,7 +341,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -365,7 +373,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,11 +383,11 @@ describe("createChildStoreManager", () => {
|
|||||||
manager.child("/handshake")
|
manager.child("/handshake")
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
expect(queries[1]?.().enabled).toBe(false)
|
||||||
|
|
||||||
connected = true
|
connected = true
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
expect(queries[1]?.().enabled).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@/types"
|
import type { Path, VcsInfo } from "@/types"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
@@ -19,6 +19,8 @@ import { QueryOptionsApi } from "../server-sync"
|
|||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
import { normalizeAgentList } from "./utils"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -32,7 +34,9 @@ export function createChildStoreManager(input: {
|
|||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
|
data: Data
|
||||||
global: {
|
global: {
|
||||||
|
path: Path
|
||||||
provider: NormalizedProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -45,7 +49,6 @@ export function createChildStoreManager(input: {
|
|||||||
const ownerPins = new WeakMap<object, Set<string>>()
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
|
||||||
const activeDirectories = new Set<string>()
|
const activeDirectories = new Set<string>()
|
||||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
@@ -120,7 +123,6 @@ export function createChildStoreManager(input: {
|
|||||||
iconCache.delete(key)
|
iconCache.delete(key)
|
||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
mcpToggles.delete(key)
|
|
||||||
activeDirectories.delete(key)
|
activeDirectories.delete(key)
|
||||||
activationToggles.delete(key)
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
@@ -186,21 +188,7 @@ export function createChildStoreManager(input: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.path(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcp(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpResourceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcpResources(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const lspQuery = useQuery(() => ({
|
const lspQuery = useQuery(() => ({
|
||||||
...input.queryOptions.lsp(key),
|
...input.queryOptions.lsp(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
@@ -209,11 +197,6 @@ export function createChildStoreManager(input: {
|
|||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.references(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
@@ -230,15 +213,24 @@ export function createChildStoreManager(input: {
|
|||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
|
const location = input.data.location.info({ directory })
|
||||||
if (pathQuery.isLoading) return EMPTY
|
return {
|
||||||
return pathQuery.data ?? EMPTY
|
state: "",
|
||||||
|
config: "",
|
||||||
|
worktree: location?.project.directory ?? "",
|
||||||
|
directory: location?.directory ?? directory,
|
||||||
|
home: input.global.path.home,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
agent: [],
|
get agent() {
|
||||||
command: [],
|
return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
|
||||||
|
},
|
||||||
|
get command() {
|
||||||
|
return input.data.location.command.list({ directory }) ?? []
|
||||||
|
},
|
||||||
get reference() {
|
get reference() {
|
||||||
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
return input.data.location.reference.list({ directory }) ?? []
|
||||||
},
|
},
|
||||||
session: [],
|
session: [],
|
||||||
sessionTotal: 0,
|
sessionTotal: 0,
|
||||||
@@ -252,13 +244,20 @@ export function createChildStoreManager(input: {
|
|||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
return !mcpQuery.isLoading
|
return input.data.location.mcp.server.list({ directory }) !== undefined
|
||||||
},
|
},
|
||||||
get mcp() {
|
get mcp() {
|
||||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
return Object.fromEntries(
|
||||||
|
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
get mcp_resource() {
|
get mcp_resource() {
|
||||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
return Object.fromEntries(
|
||||||
|
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
|
||||||
|
`${resource.server}:${resource.uri}`,
|
||||||
|
resource,
|
||||||
|
]),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
@@ -266,7 +265,11 @@ export function createChildStoreManager(input: {
|
|||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
},
|
},
|
||||||
vcs: vcsStore.value,
|
get vcs() {
|
||||||
|
const vcs = input.data.location.vcs.info({ directory })
|
||||||
|
if (!vcs) return vcsStore.value
|
||||||
|
return { branch: vcs.branch.current, default_branch: vcs.branch.default }
|
||||||
|
},
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
session_message: {},
|
session_message: {},
|
||||||
@@ -275,7 +278,6 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
|
||||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
@@ -339,7 +341,6 @@ export function createChildStoreManager(input: {
|
|||||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||||
if (mcpDirectories.has(key)) return
|
if (mcpDirectories.has(key)) return
|
||||||
mcpDirectories.add(key)
|
mcpDirectories.add(key)
|
||||||
mcpToggles.get(key)?.(true)
|
|
||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +357,6 @@ export function createChildStoreManager(input: {
|
|||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
mcpToggles.get(key)?.(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
|
|||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
|
import { createServerData } from "@opencode-ai/client/solid"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerPermissionState } from "./permission"
|
import { createServerPermissionState } from "./permission"
|
||||||
import { createServerNotificationState } from "./notification"
|
import { createServerNotificationState } from "./notification"
|
||||||
@@ -99,7 +100,13 @@ function createServerController(
|
|||||||
) {
|
) {
|
||||||
const connKey = ServerConnection.key(conn)
|
const connKey = ServerConnection.key(conn)
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
const sync = createServerSyncContext(sdk)
|
const data = createServerData({
|
||||||
|
api: () => sdk.api,
|
||||||
|
event: sdk.event,
|
||||||
|
connection: sdk.connection,
|
||||||
|
directory: "",
|
||||||
|
})
|
||||||
|
const sync = createServerSyncContext(sdk, data)
|
||||||
const permission = createServerPermissionState({ sdk, sync })
|
const permission = createServerPermissionState({ sdk, sync })
|
||||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||||
|
|
||||||
@@ -134,6 +141,7 @@ function createServerController(
|
|||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
data,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
isLocal,
|
isLocal,
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = input.sdk.event.listen((e) => {
|
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (
|
if (
|
||||||
event.type !== "session.execution.succeeded" &&
|
event.type !== "session.execution.succeeded" &&
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
return Object.values(config).some(isNonAllowRule)
|
return Object.values(config).some(isNonAllowRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||||
|
|
||||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
void respondPending(event.properties, e.name)
|
void respondPending(event.properties, e.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = input.sdk.event.listen((event) => {
|
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||||
if (ready()) {
|
if (ready()) {
|
||||||
handlePermission(event)
|
handlePermission(event)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
|
||||||
let starts = 0
|
|
||||||
const start = () => starts++
|
|
||||||
|
|
||||||
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
|
||||||
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
|
||||||
|
|
||||||
expect(starts).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves current permission requests", () => {
|
test("preserves current permission requests", () => {
|
||||||
@@ -43,57 +31,3 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("current event buffering", () => {
|
|
||||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
|
||||||
directory: "/repo",
|
|
||||||
payload: adaptServerEvent({
|
|
||||||
id,
|
|
||||||
created: 1,
|
|
||||||
type: "session.text.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
|
||||||
} as OpenCodeEvent),
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
|
||||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
|
||||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("coalesces current tool input deltas by tool ID", () => {
|
|
||||||
const current = (eventID: string, id: string, delta: string) =>
|
|
||||||
adaptServerEvent({
|
|
||||||
id: eventID,
|
|
||||||
created: 1,
|
|
||||||
type: "session.tool.input.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
const result = coalesceServerEvents([
|
|
||||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
|
||||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
|
||||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
|
||||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves boundaries between distinct delta streams", () => {
|
|
||||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
|
||||||
|
|
||||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves current event order when enqueuing", () => {
|
|
||||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
|
||||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
|
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||||
import type { Event } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { type Accessor, onCleanup } from "solid-js"
|
||||||
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection } from "./servers"
|
import { ServerConnection } from "./servers"
|
||||||
@@ -12,85 +11,15 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
|
||||||
type CurrentDelta = Extract<
|
|
||||||
OpenCodeEvent,
|
|
||||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
|
||||||
>
|
|
||||||
|
|
||||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
|
||||||
queue.push(event)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|
||||||
const output: QueuedServerEvent[] = []
|
|
||||||
events.forEach((event) => {
|
|
||||||
const current = currentDelta(event.payload.current)
|
|
||||||
if (current) {
|
|
||||||
const previous = output[output.length - 1]
|
|
||||||
const prior = currentDelta(previous?.payload.current)
|
|
||||||
if (
|
|
||||||
previous &&
|
|
||||||
prior &&
|
|
||||||
previous.directory === event.directory &&
|
|
||||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
|
||||||
) {
|
|
||||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
|
||||||
const data =
|
|
||||||
current.type === "session.compaction.delta"
|
|
||||||
? { ...current.data, text: fragment }
|
|
||||||
: { ...current.data, delta: fragment }
|
|
||||||
output[output.length - 1] = {
|
|
||||||
directory: event.directory,
|
|
||||||
payload: {
|
|
||||||
...event.payload,
|
|
||||||
properties: data,
|
|
||||||
current: { ...current, data } as CurrentDelta,
|
|
||||||
} as ServerEvent,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(event)
|
|
||||||
})
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
|
||||||
if (
|
|
||||||
event?.type === "session.text.delta" ||
|
|
||||||
event?.type === "session.reasoning.delta" ||
|
|
||||||
event?.type === "session.tool.input.delta" ||
|
|
||||||
event?.type === "session.compaction.delta"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaKey(event: CurrentDelta) {
|
|
||||||
if (event.type === "session.tool.input.delta")
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
|
||||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaFragment(event: CurrentDelta) {
|
|
||||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
|
||||||
if (!event.persisted) return
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||||
|
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||||
|
export type ServerConnectionStatus = ClientConnectionStatus
|
||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
@@ -101,231 +30,51 @@ type ServerSDKBase = {
|
|||||||
attempt: Accessor<number>
|
attempt: Accessor<number>
|
||||||
error: Accessor<string | undefined>
|
error: Accessor<string | undefined>
|
||||||
}
|
}
|
||||||
event: {
|
eventByDir: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
}
|
}
|
||||||
|
event: {
|
||||||
|
on: CurrentEventEmitter["on"]
|
||||||
|
listen: CurrentEventEmitter["listen"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
|
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||||
|
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||||
|
|
||||||
const eventFetch = (() => {
|
const connection = createClientConnection(api, {
|
||||||
if (!platform.fetch || !server) return
|
flushInterval: 16,
|
||||||
try {
|
pageLifecycle: true,
|
||||||
const url = new URL(server.http.url)
|
onEvent(event) {
|
||||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
emitter.emit(event.type, event)
|
||||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||||
} catch {
|
},
|
||||||
return
|
log: {
|
||||||
}
|
info(message, data) {
|
||||||
})()
|
if (message !== "event stream disconnected") return
|
||||||
|
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
},
|
||||||
const emitter = createGlobalEmitter<{
|
},
|
||||||
[key: string]: ServerEvent
|
|
||||||
}>()
|
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
|
||||||
const FLUSH_FRAME_MS = 16
|
|
||||||
const STREAM_YIELD_MS = 8
|
|
||||||
const CONNECT_TIMEOUT_MS = 2_000
|
|
||||||
const RECONNECT_DELAY_MS = 1_000
|
|
||||||
|
|
||||||
let queue: Queued[] = []
|
|
||||||
let buffer: Queued[] = []
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
function flush() {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
|
|
||||||
if (queue.length === 0) return
|
|
||||||
|
|
||||||
const events = queue
|
|
||||||
queue = buffer
|
|
||||||
buffer = events
|
|
||||||
queue.length = 0
|
|
||||||
|
|
||||||
last = Date.now()
|
|
||||||
const output = coalesceServerEvents(events)
|
|
||||||
batch(() => {
|
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
|
||||||
})
|
|
||||||
|
|
||||||
buffer.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule() {
|
|
||||||
if (timer) return
|
|
||||||
const elapsed = Date.now() - last
|
|
||||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish(event: OpenCodeEvent) {
|
|
||||||
const directory = event.location?.directory ?? "global"
|
|
||||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let attempt: AbortController | undefined
|
|
||||||
let run: Promise<void> | undefined
|
|
||||||
let started = false
|
|
||||||
let generation = 0
|
|
||||||
const [connection, setConnection] = createStore<{
|
|
||||||
status: ServerConnectionStatus
|
|
||||||
attempt: number
|
|
||||||
error?: string
|
|
||||||
}>({ status: "connecting", attempt: 0 })
|
|
||||||
|
|
||||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
|
||||||
let connectedAt: number | undefined
|
|
||||||
|
|
||||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
|
||||||
const request = new AbortController()
|
|
||||||
const cancel = () => request.abort(signal.reason)
|
|
||||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
|
||||||
signal.addEventListener("abort", cancel, { once: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Open the event stream and validate its initial handshake.
|
|
||||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
|
||||||
const first = await iterator.next()
|
|
||||||
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (first.done) {
|
|
||||||
const error =
|
|
||||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
|
||||||
return { error, connectedAt }
|
|
||||||
}
|
|
||||||
if (first.value.type !== "server.connected")
|
|
||||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
|
||||||
|
|
||||||
// Publish the connected state before forwarding live events.
|
|
||||||
clearTimeout(timeout)
|
|
||||||
publish(first.value)
|
|
||||||
connectedAt = Date.now()
|
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
||||||
|
|
||||||
// Forward events until the stream closes or this connection is cancelled.
|
|
||||||
let yielded = Date.now()
|
|
||||||
while (!signal.aborted) {
|
|
||||||
const event = await iterator.next()
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
|
||||||
publish(event.value)
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
|
||||||
yielded = Date.now()
|
|
||||||
await wait(0, signal)
|
|
||||||
}
|
|
||||||
return { error: undefined, connectedAt }
|
|
||||||
} catch (error) {
|
|
||||||
return { error, connectedAt }
|
|
||||||
} finally {
|
|
||||||
request.abort()
|
|
||||||
clearTimeout(timeout)
|
|
||||||
signal.removeEventListener("abort", cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runStream(active: number) {
|
|
||||||
let retries = 0
|
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
|
||||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
|
||||||
const controller = new AbortController()
|
|
||||||
attempt = controller
|
|
||||||
const onAbort = () => controller.abort()
|
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
|
||||||
const result = await connect(controller.signal)
|
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
|
||||||
|
|
||||||
if (abort.signal.aborted || !started || generation !== active) {
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
|
||||||
retries += 1
|
|
||||||
const message =
|
|
||||||
result.error === undefined
|
|
||||||
? undefined
|
|
||||||
: result.error instanceof Error
|
|
||||||
? result.error.message
|
|
||||||
: String(result.error)
|
|
||||||
console.info("[global-sdk] event stream disconnected", {
|
|
||||||
url: server.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
attempt: retries,
|
|
||||||
error: message,
|
|
||||||
})
|
|
||||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
|
||||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (started) return run
|
|
||||||
started = true
|
|
||||||
const active = ++generation
|
|
||||||
const previous = run
|
|
||||||
const current = (async () => {
|
|
||||||
if (previous) await previous
|
|
||||||
await runStream(active)
|
|
||||||
})().finally(() => {
|
|
||||||
if (run !== current) return
|
|
||||||
run = undefined
|
|
||||||
flush()
|
|
||||||
})
|
|
||||||
run = current
|
|
||||||
return run
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
started = false
|
|
||||||
generation++
|
|
||||||
attempt?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
makeEventListener(window, "pagehide", stop)
|
|
||||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
|
||||||
void start()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
dirEmitter.clear()
|
||||||
abort.abort()
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
queue = []
|
|
||||||
buffer = []
|
|
||||||
emitter.clear()
|
emitter.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
api,
|
api,
|
||||||
connection: {
|
connection,
|
||||||
status: () => connection.status,
|
eventByDir: {
|
||||||
attempt: () => connection.attempt,
|
on: dirEmitter.on.bind(dirEmitter),
|
||||||
error: () => connection.error,
|
listen: dirEmitter.listen.bind(dirEmitter),
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
@@ -365,7 +114,7 @@ export type DirectorySDK = {
|
|||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
const unsub = serverSDK.event.on(directory, (event) => {
|
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||||
McpListInput,
|
|
||||||
McpResourceCatalogInput,
|
|
||||||
SessionApi,
|
|
||||||
SessionInfo,
|
|
||||||
SessionListInput,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import {
|
import {
|
||||||
loadActiveSessionsQuery,
|
loadActiveSessionsQuery,
|
||||||
loadMcpQuery,
|
|
||||||
loadMcpResourcesQuery,
|
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
shouldRefreshWorkspaceSessions,
|
shouldRefreshWorkspaceSessions,
|
||||||
@@ -21,56 +13,6 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
|
||||||
|
|
||||||
describe("MCP queries", () => {
|
|
||||||
test("loads current servers for the requested location", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const result = await queryClient.fetchQuery(
|
|
||||||
loadMcpQuery(ServerScope.local, "/project", {
|
|
||||||
list: async (input: McpListInput = {}) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
|
||||||
data: [
|
|
||||||
{ name: "docs", status: { status: "connected" } },
|
|
||||||
{ name: "search", status: { status: "pending" } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as unknown as McpApi),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
|
||||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads and keys the current resource catalog", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const result = await queryClient.fetchQuery(
|
|
||||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
|
||||||
resource: {
|
|
||||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
|
||||||
data: {
|
|
||||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
|
||||||
templates: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as McpApi),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
|
||||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("active session query", () => {
|
describe("active session query", () => {
|
||||||
test("loads active sessions immediately and once per server cache", async () => {
|
test("loads active sessions immediately and once per server cache", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ import {
|
|||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
clearProviderRev,
|
clearProviderRev,
|
||||||
loadAgentsQuery,
|
|
||||||
loadCommands,
|
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
loadIntegrationsQuery,
|
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
loadReferencesQuery,
|
|
||||||
} from "./global-sync/bootstrap"
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
@@ -40,21 +35,15 @@ import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
|||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import type {
|
import type {
|
||||||
McpListInput,
|
|
||||||
McpListOutput,
|
|
||||||
McpResource,
|
|
||||||
McpResourceCatalogInput,
|
|
||||||
McpResourceCatalogOutput,
|
|
||||||
McpServer,
|
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { createCatalogSync } from "./server-sync/catalog"
|
|
||||||
import { createConnectionSync } from "./server-sync/connection"
|
import { createConnectionSync } from "./server-sync/connection"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
|
||||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
const type = event.current?.type ?? event.type
|
const type = event.current?.type ?? event.type
|
||||||
@@ -88,16 +77,6 @@ const SESSION_LIST_EVENTS = new Set([
|
|||||||
"session.usage.updated",
|
"session.usage.updated",
|
||||||
])
|
])
|
||||||
|
|
||||||
type McpListApi = {
|
|
||||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type McpResourceApi = {
|
|
||||||
readonly resource: {
|
|
||||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||||
initialData?: undefined
|
initialData?: undefined
|
||||||
queryKey: K
|
queryKey: K
|
||||||
@@ -107,47 +86,6 @@ type SessionActiveApi = {
|
|||||||
readonly active: () => Promise<SessionActiveOutput>
|
readonly active: () => Promise<SessionActiveOutput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadMcpQuery = (
|
|
||||||
scope: ServerScope,
|
|
||||||
directory: string,
|
|
||||||
api: McpListApi,
|
|
||||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
|
||||||
queryOptions<
|
|
||||||
Record<string, McpServer["status"]>,
|
|
||||||
Error,
|
|
||||||
Record<string, McpServer["status"]>,
|
|
||||||
readonly [ServerScope, string, "mcp"]
|
|
||||||
>({
|
|
||||||
queryKey: [scope, directory, "mcp"] as const,
|
|
||||||
queryFn: async () => {
|
|
||||||
return api
|
|
||||||
.list({ location: { directory } })
|
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadMcpResourcesQuery = (
|
|
||||||
scope: ServerScope,
|
|
||||||
directory: string,
|
|
||||||
api: McpResourceApi,
|
|
||||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
|
||||||
queryOptions<
|
|
||||||
Record<string, McpResource>,
|
|
||||||
Error,
|
|
||||||
Record<string, McpResource>,
|
|
||||||
readonly [ServerScope, string, "mcpResources"]
|
|
||||||
>({
|
|
||||||
queryKey: [scope, directory, "mcpResources"] as const,
|
|
||||||
queryFn: async () => {
|
|
||||||
return api.resource
|
|
||||||
.catalog({ location: { directory } })
|
|
||||||
.then((result) =>
|
|
||||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholderData: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
@@ -194,21 +132,15 @@ export function reconcileActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
@@ -237,7 +169,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queries: [
|
queries: [
|
||||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
{ ...queryOptionsApi.path(), enabled: connected() },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() => ({
|
const activeSessionsQuery = useQuery(() => ({
|
||||||
@@ -338,10 +270,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (!connected()) return
|
if (!connected()) return
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory) => {
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
void Promise.all([
|
||||||
.then((commands) => setStore("command", commands))
|
data.location.command.sync({ directory }),
|
||||||
.catch((err) => {
|
data.location.mcp.server.sync({ directory }),
|
||||||
|
data.location.mcp.resource.sync({ directory }),
|
||||||
|
]).catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||||
@@ -357,32 +291,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
data,
|
||||||
global: {
|
global: {
|
||||||
|
get path() {
|
||||||
|
return globalStore.path
|
||||||
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
return globalStore.provider
|
return globalStore.provider
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const catalog = createCatalogSync({
|
|
||||||
scope: serverSDK.scope,
|
|
||||||
queryClient,
|
|
||||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
|
||||||
load: (directory) =>
|
|
||||||
Promise.all([
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
|
||||||
]).then(() => undefined),
|
|
||||||
})
|
|
||||||
const refreshVcs = (directory: string) =>
|
|
||||||
serverSDK.api.vcs
|
|
||||||
.get({ location: { directory } })
|
|
||||||
.then((result) =>
|
|
||||||
children.vcs(directory, {
|
|
||||||
branch: result.data.branch.current,
|
|
||||||
default_branch: result.data.branch.default,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
const connection = createConnectionSync({
|
const connection = createConnectionSync({
|
||||||
status: serverSDK.connection.status,
|
status: serverSDK.connection.status,
|
||||||
invalidate: () => {
|
invalidate: () => {
|
||||||
@@ -400,7 +318,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.filter(children.active)
|
.filter(children.active)
|
||||||
.forEach((directory) => {
|
.forEach((directory) => {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
void data.location.sync({ directory }).catch(() => undefined)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -498,8 +416,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
children.pin(key)
|
children.pin(key)
|
||||||
const promise = Promise.resolve().then(async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const initial = child[0].status === "loading"
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
data.location.sync({ directory }),
|
||||||
bootstrapDirectory({
|
bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -518,7 +436,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
}),
|
}),
|
||||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -568,7 +485,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
@@ -628,8 +545,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
catalog.handleEvent({ type: eventType, directory })
|
|
||||||
connection.handleEvent({ type: eventType, directory })
|
connection.handleEvent({ type: eventType, directory })
|
||||||
|
if (eventType === "catalog.updated" || eventType === "integration.updated") {
|
||||||
|
void queryClient.invalidateQueries(queryOptionsApi.providers(directory === "global" ? null : key))
|
||||||
|
}
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
@@ -661,21 +580,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
eventType === "agent.updated"
|
eventType === "agent.updated"
|
||||||
)
|
)
|
||||||
queue.push(key)
|
queue.push(key)
|
||||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
|
||||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
|
||||||
const [store, setStore] = existing
|
const [store, setStore] = existing
|
||||||
if (eventType === "agent.updated")
|
|
||||||
void queryClient
|
|
||||||
.fetchQuery(queryOptionsApi.agents(key))
|
|
||||||
.then((data) => setStore("agent", data))
|
|
||||||
.catch(() => {})
|
|
||||||
if (eventType === "command.updated")
|
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
|
||||||
.then((commands) => setStore("command", commands))
|
|
||||||
.catch(() => {})
|
|
||||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||||
const projected = toDirectoryEvent(event)
|
const projected = toDirectoryEvent(event)
|
||||||
if (projected)
|
if (projected && eventType !== "vcs.branch.updated")
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: projected,
|
event: projected,
|
||||||
directory,
|
directory,
|
||||||
@@ -694,7 +602,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
loadReferences: () => {
|
loadReferences: () => {
|
||||||
if (!children.active(key)) return
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -719,6 +627,26 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshProviders = () => {
|
||||||
|
const locations = Object.keys(children.children).filter(children.active)
|
||||||
|
if (locations.length === 0) locations.push(data.location.default().directory)
|
||||||
|
locations.filter(Boolean).forEach((directory) => {
|
||||||
|
data.location.provider.invalidate({ directory })
|
||||||
|
data.location.model.invalidate({ directory })
|
||||||
|
})
|
||||||
|
return Promise.all(
|
||||||
|
[
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
}),
|
||||||
|
...locations.filter(Boolean).flatMap((directory) => [
|
||||||
|
data.location.provider.sync({ directory }),
|
||||||
|
data.location.model.sync({ directory }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
).then(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: async (config: Config) => {
|
mutationFn: async (config: Config) => {
|
||||||
// TODO: Restore config updates when the V2 client exposes a config API.
|
// TODO: Restore config updates when the V2 client exposes a config API.
|
||||||
@@ -749,7 +677,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
refreshProviders: catalog.refreshActive,
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
@@ -788,8 +716,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
platform.openExternal(attempt.data.url)
|
platform.openExternal(attempt.data.url)
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
data.location.mcp.server.invalidate({ directory: key })
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
data.location.mcp.resource.invalidate({ directory: key })
|
||||||
|
await Promise.all([
|
||||||
|
data.location.mcp.server.sync({ directory: key }),
|
||||||
|
data.location.mcp.resource.sync({ directory: key }),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -797,8 +729,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
|
||||||
const inner = createServerSyncContextInner(serverSDK)
|
const inner = createServerSyncContextInner(serverSDK, data)
|
||||||
return Object.assign(inner, {
|
return Object.assign(inner, {
|
||||||
ensureDirSyncContext: createRefCountMap(
|
ensureDirSyncContext: createRefCountMap(
|
||||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||||
|
|||||||
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useData = () => {
|
||||||
|
const server = useServer()
|
||||||
|
return server.ctx.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { createEffect, type Accessor } from "solid-js"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
|
|
||||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
export function useIntegrations(directory: Accessor<string | undefined>) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const query = createQuery(() => {
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
const value = directory()
|
const value = directory()
|
||||||
return {
|
void data.location.integration.sync(value ? { directory: value } : undefined).catch(() => undefined)
|
||||||
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
|
||||||
enabled: serverSDK.connection.status() === "connected",
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Schema } from "effect"
|
|||||||
import type { ServerConnection } from "@/context/servers"
|
import type { ServerConnection } from "@/context/servers"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
|
||||||
export function DirectoryDataProvider(
|
export function DirectoryDataProvider(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -25,6 +27,8 @@ export function DirectoryDataProvider(
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const slug = createMemo(() => base64Encode(directory()))
|
const slug = createMemo(() => base64Encode(directory()))
|
||||||
@@ -52,6 +56,21 @@ export function DirectoryDataProvider(
|
|||||||
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
|
const ref = { directory: directory() }
|
||||||
|
void data.location.sync(ref).catch(() => undefined)
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
void Promise.allSettled([
|
||||||
|
data.session.sync(sessionID, { children: true }),
|
||||||
|
data.session.pending.sync(sessionID),
|
||||||
|
data.session.message.sync(sessionID),
|
||||||
|
data.session.permission.sync(sessionID),
|
||||||
|
data.session.form.sync(sessionID),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
@@ -17,7 +16,6 @@ export function createNewSessionDraftController(props: {
|
|||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}) {
|
}) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
@@ -29,7 +27,6 @@ export function createNewSessionDraftController(props: {
|
|||||||
const controls = createPromptInputController({
|
const controls = createPromptInputController({
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
queryOptions: serverSync.queryOptions,
|
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls({
|
const projectControls = createPromptProjectControls({
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionLineage } from "./session/session-lineage"
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
@@ -384,7 +384,6 @@ export default function Page() {
|
|||||||
const inputController = createPromptInputController({
|
const inputController = createPromptInputController({
|
||||||
sessionKey: controller.identity.sessionKey,
|
sessionKey: controller.identity.sessionKey,
|
||||||
sessionID: () => controller.identity.params.id,
|
sessionID: () => controller.identity.params.id,
|
||||||
queryOptions: serverSync.queryOptions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionPanelKey = createMemo(() =>
|
const sessionPanelKey = createMemo(() =>
|
||||||
@@ -2128,9 +2127,12 @@ export default function Page() {
|
|||||||
const id = controller.data.parentID()
|
const id = controller.data.parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(
|
navigate(
|
||||||
|
sessionHref(
|
||||||
controller.identity.params.serverKey
|
controller.identity.params.serverKey
|
||||||
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
? requireServerKey(controller.identity.params.serverKey)
|
||||||
: legacySessionHref(sdk().directory, id),
|
: ServerConnection.key(serverSDK.server),
|
||||||
|
id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
setPromptRef: (el) => {
|
setPromptRef: (el) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||||
@@ -8,28 +7,26 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
|||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { useData } from "@/context/server"
|
||||||
|
|
||||||
export function createPromptInputController(input: {
|
export function createPromptInputController(input: {
|
||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
queryOptions: Pick<QueryOptionsApi, "agents">
|
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const view = layout.view(input.sessionKey)
|
const view = layout.view(input.sessionKey)
|
||||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
|
||||||
|
|
||||||
return createMemo<PromptInputControls>(() => {
|
return createMemo<PromptInputControls>(() => {
|
||||||
return {
|
return {
|
||||||
@@ -37,14 +34,14 @@ export function createPromptInputController(input: {
|
|||||||
available: sync().data.agent,
|
available: sync().data.agent,
|
||||||
options: local.agent.list().map((agent) => agent.name),
|
options: local.agent.list().map((agent) => agent.name),
|
||||||
current: local.agent.current()?.name ?? "",
|
current: local.agent.current()?.name ?? "",
|
||||||
loading: agentsQuery.isLoading,
|
loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
|
||||||
visible: local.agent.visible(),
|
visible: local.agent.visible(),
|
||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
selection: input.model ?? local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
|
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
import { useData } from "@/context/server"
|
||||||
|
|
||||||
export const todoState = (input: {
|
export const todoState = (input: {
|
||||||
count: number
|
count: number
|
||||||
@@ -34,29 +34,13 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const queryClient = useQueryClient()
|
const data = useData()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
|
createEffect(() => {
|
||||||
const shells = createQuery(() => ({
|
if (!params.id || serverSDK.connection.status() !== "connected") return
|
||||||
queryKey: shellKey(),
|
void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
|
||||||
enabled: !!params.id && serverSDK.connection.status() === "connected",
|
})
|
||||||
queryFn: () =>
|
|
||||||
sdk()
|
|
||||||
.api.shell.list({ location: { directory: sdk().directory } })
|
|
||||||
.then((result) => result.data ?? []),
|
|
||||||
}))
|
|
||||||
onCleanup(
|
|
||||||
sdk().event.listen((event) => {
|
|
||||||
if (
|
|
||||||
event.details.type !== "shell.created" &&
|
|
||||||
event.details.type !== "shell.exited" &&
|
|
||||||
event.details.type !== "shell.deleted"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||||
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
||||||
@@ -171,7 +155,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||||
if (
|
if (
|
||||||
blocking.some(
|
blocking.some(
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import type { SessionController } from "@/pages/session/session-controller"
|
import type { SessionController } from "@/pages/session/session-controller"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||||
import { createTimelineProjection } from "./projection"
|
import { createTimelineProjection } from "./projection"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
const emptyMessages: Message[] = []
|
const emptyMessages: Message[] = []
|
||||||
const emptyParts: Part[] = []
|
const emptyParts: Part[] = []
|
||||||
@@ -46,6 +48,7 @@ export function createTimelineController(input: {
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -146,19 +149,22 @@ export function createTimelineController(input: {
|
|||||||
if (!id || pending.unshare || !shareEnabled()) return
|
if (!id || pending.unshare || !shareEnabled()) return
|
||||||
}
|
}
|
||||||
const href = (id: string) =>
|
const href = (id: string) =>
|
||||||
|
sessionHref(
|
||||||
input.session.identity.params.serverKey
|
input.session.identity.params.serverKey
|
||||||
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
|
? requireServerKey(input.session.identity.params.serverKey)
|
||||||
: legacySessionHref(sdk().directory, id)
|
: ServerConnection.key(serverSDK.server),
|
||||||
|
id,
|
||||||
|
)
|
||||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||||
if (input.session.identity.params.id !== id) return
|
if (input.session.identity.params.id !== id) return
|
||||||
if (parent) return navigate(href(parent))
|
if (parent) return navigate(href(parent))
|
||||||
if (next) return navigate(href(next))
|
if (next) return navigate(href(next))
|
||||||
if (input.session.identity.params.serverKey)
|
|
||||||
return tabs.newDraft({
|
return tabs.newDraft({
|
||||||
server: requireServerKey(input.session.identity.params.serverKey),
|
server: input.session.identity.params.serverKey
|
||||||
|
? requireServerKey(input.session.identity.params.serverKey)
|
||||||
|
: ServerConnection.key(serverSDK.server),
|
||||||
directory: sdk().directory,
|
directory: sdk().directory,
|
||||||
})
|
})
|
||||||
navigate(`/${input.session.identity.params.dir}/session`)
|
|
||||||
}
|
}
|
||||||
const exportSession = async (id: string) => {
|
const exportSession = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"./promise": "./src/promise/index.ts",
|
"./promise": "./src/promise/index.ts",
|
||||||
"./promise/api": "./src/promise/api.ts",
|
"./promise/api": "./src/promise/api.ts",
|
||||||
"./service": "./src/promise/service.ts",
|
"./service": "./src/promise/service.ts",
|
||||||
|
"./solid": "./src/solid/index.ts",
|
||||||
"./effect": "./src/effect/index.ts",
|
"./effect": "./src/effect/index.ts",
|
||||||
"./effect/api": "./src/effect/api.ts",
|
"./effect/api": "./src/effect/api.ts",
|
||||||
"./effect/service": "./src/effect/service.ts"
|
"./effect/service": "./src/effect/service.ts"
|
||||||
@@ -36,11 +37,15 @@
|
|||||||
"@opencode-ai/protocol": "workspace:*"
|
"@opencode-ai/protocol": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.101"
|
"effect": "4.0.0-beta.101",
|
||||||
|
"solid-js": ">=1.9.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"effect": {
|
"effect": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:",
|
||||||
|
"solid-js": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
|
||||||
|
|
||||||
|
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||||
|
export type ClientConnectionEvent = {
|
||||||
|
readonly type: "client.connection"
|
||||||
|
readonly created: number
|
||||||
|
readonly data: {
|
||||||
|
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||||
|
readonly attempt: number
|
||||||
|
readonly error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientConnectionOptions = {
|
||||||
|
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||||
|
readonly onEvent: (event: OpenCodeEvent) => void
|
||||||
|
readonly flushInterval?: number
|
||||||
|
readonly pageLifecycle?: boolean
|
||||||
|
readonly log?: {
|
||||||
|
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||||
|
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectTimeout = 2_000
|
||||||
|
const reconnectDelay = 1_000
|
||||||
|
const connectionHistoryLimit = 50
|
||||||
|
|
||||||
|
type CurrentDelta = Extract<
|
||||||
|
OpenCodeEvent,
|
||||||
|
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||||
|
>
|
||||||
|
|
||||||
|
export function coalesceClientEvents(events: OpenCodeEvent[]) {
|
||||||
|
return events.reduce<OpenCodeEvent[]>((output, event) => {
|
||||||
|
const current = currentDelta(event)
|
||||||
|
const previous = output[output.length - 1]
|
||||||
|
const prior = currentDelta(previous)
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
!prior ||
|
||||||
|
previous?.location?.directory !== event.location?.directory ||
|
||||||
|
currentDeltaKey(prior) !== currentDeltaKey(current)
|
||||||
|
) {
|
||||||
|
output.push(event)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||||
|
output[output.length - 1] = {
|
||||||
|
...current,
|
||||||
|
data:
|
||||||
|
current.type === "session.compaction.delta"
|
||||||
|
? { ...current.data, text: fragment }
|
||||||
|
: { ...current.data, delta: fragment },
|
||||||
|
} as CurrentDelta
|
||||||
|
return output
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
|
if (
|
||||||
|
event?.type === "session.text.delta" ||
|
||||||
|
event?.type === "session.reasoning.delta" ||
|
||||||
|
event?.type === "session.tool.input.delta" ||
|
||||||
|
event?.type === "session.compaction.delta"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaKey(event: CurrentDelta) {
|
||||||
|
if (event.type === "session.tool.input.delta")
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||||
|
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaFragment(event: CurrentDelta) {
|
||||||
|
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
|
||||||
|
const abort = new AbortController()
|
||||||
|
const history: ClientConnectionEvent[] = []
|
||||||
|
const [connection, setConnection] = createStore<{
|
||||||
|
status: ClientConnectionStatus
|
||||||
|
attempt: number
|
||||||
|
error?: string
|
||||||
|
}>({ status: "connecting", attempt: 0 })
|
||||||
|
let api = initialApi
|
||||||
|
let pending: OpenCodeEvent[] = []
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let stream: AbortController | undefined
|
||||||
|
let run: Promise<void> | undefined
|
||||||
|
let started = false
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||||
|
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||||
|
if (history.length > connectionHistoryLimit) history.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(event: OpenCodeEvent) {
|
||||||
|
pending.push(event)
|
||||||
|
if (flushTimer) return
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = undefined
|
||||||
|
const events = pending
|
||||||
|
pending = []
|
||||||
|
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
|
||||||
|
}, options.flushInterval ?? 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(signal: AbortSignal, attempt: number) {
|
||||||
|
let connectedAt: number | undefined
|
||||||
|
const request = new AbortController()
|
||||||
|
const cancel = () => request.abort(signal.reason)
|
||||||
|
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||||
|
signal.addEventListener("abort", cancel, { once: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||||
|
options.log?.info?.("event stream connecting", { attempt })
|
||||||
|
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||||
|
const first = await iterator.next()
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (first.done)
|
||||||
|
return {
|
||||||
|
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||||
|
connectedAt,
|
||||||
|
}
|
||||||
|
if (first.value.type !== "server.connected")
|
||||||
|
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
record("connected", attempt)
|
||||||
|
connectedAt = Date.now()
|
||||||
|
options.log?.info?.("event stream connected")
|
||||||
|
publish(first.value)
|
||||||
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const event = await iterator.next()
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||||
|
if ("durable" in event.value)
|
||||||
|
options.log?.debug?.("event", {
|
||||||
|
type: event.value.type,
|
||||||
|
aggregateID: event.value.durable.aggregateID,
|
||||||
|
seq: event.value.durable.seq,
|
||||||
|
})
|
||||||
|
publish(event.value)
|
||||||
|
}
|
||||||
|
return { error: undefined, connectedAt }
|
||||||
|
} catch (error) {
|
||||||
|
return { error, connectedAt }
|
||||||
|
} finally {
|
||||||
|
request.abort()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
signal.removeEventListener("abort", cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStream(active: number) {
|
||||||
|
let attempt = 0
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
|
||||||
|
const controller = new AbortController()
|
||||||
|
stream = controller
|
||||||
|
const cancel = () => controller.abort(abort.signal.reason)
|
||||||
|
abort.signal.addEventListener("abort", cancel)
|
||||||
|
const result = await connect(controller.signal, attempt)
|
||||||
|
abort.signal.removeEventListener("abort", cancel)
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
|
||||||
|
attempt += 1
|
||||||
|
const message = errorMessage(result.error)
|
||||||
|
record("disconnected", attempt, message)
|
||||||
|
options.log?.info?.("event stream disconnected", { attempt, error: message })
|
||||||
|
setConnection({ status: "reconnecting", attempt, error: message })
|
||||||
|
|
||||||
|
if (options.reconnect) {
|
||||||
|
const next = await options.reconnect(controller.signal).catch((error) => {
|
||||||
|
if (!controller.signal.aborted)
|
||||||
|
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
|
||||||
|
})
|
||||||
|
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
|
||||||
|
if (next) {
|
||||||
|
api = next
|
||||||
|
if (attempt === 1) continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await wait(reconnectDelay, controller.signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) return run
|
||||||
|
started = true
|
||||||
|
const active = ++generation
|
||||||
|
const previous = run
|
||||||
|
const current = (async () => {
|
||||||
|
if (previous) await previous
|
||||||
|
await runStream(active)
|
||||||
|
})().finally(() => {
|
||||||
|
if (run !== current) return
|
||||||
|
run = undefined
|
||||||
|
})
|
||||||
|
run = current
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
started = false
|
||||||
|
generation += 1
|
||||||
|
stream?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (options.pageLifecycle) {
|
||||||
|
const pagehide = () => stop()
|
||||||
|
const pageshow = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) void start()
|
||||||
|
}
|
||||||
|
window.addEventListener("pagehide", pagehide)
|
||||||
|
window.addEventListener("pageshow", pageshow)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pagehide", pagehide)
|
||||||
|
window.removeEventListener("pageshow", pageshow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
void start()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
abort.abort()
|
||||||
|
if (flushTimer) clearTimeout(flushTimer)
|
||||||
|
pending = []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: () => connection.status,
|
||||||
|
attempt: () => connection.attempt,
|
||||||
|
error: () => connection.error,
|
||||||
|
internal: {
|
||||||
|
history: () => history.slice(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error === undefined) return undefined
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./data"
|
||||||
|
export * from "./connection"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { OpenCodeEvent } from "../src/promise"
|
||||||
|
import { coalesceClientEvents } from "../src/solid/connection"
|
||||||
|
|
||||||
|
describe("coalesceClientEvents", () => {
|
||||||
|
const delta = (id: string, value: string, ordinal = 0) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
created: 1,
|
||||||
|
type: "session.text.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||||
|
}) as OpenCodeEvent
|
||||||
|
|
||||||
|
test("merges adjacent deltas for the same stream", () => {
|
||||||
|
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("coalesces tool input deltas by tool ID", () => {
|
||||||
|
const current = (eventID: string, id: string, value: string) =>
|
||||||
|
({
|
||||||
|
id: eventID,
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
|
||||||
|
}) as OpenCodeEvent
|
||||||
|
const result = coalesceClientEvents([
|
||||||
|
current("evt_1", "call_1", "{"),
|
||||||
|
current("evt_2", "call_1", "}"),
|
||||||
|
current("evt_3", "call_2", "[]"),
|
||||||
|
])
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||||
|
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
|
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||||
|
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) {
|
|||||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||||
: undefined,
|
: undefined,
|
||||||
version: cli.version,
|
version: cli.version,
|
||||||
command: [...cli.command, "serve", "--service"],
|
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||||
})
|
})
|
||||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||||
|
|||||||
@@ -1,177 +1,39 @@
|
|||||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||||
|
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { errorMessage } from "../util/error"
|
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useLog } from "./log"
|
import { useLog } from "./log"
|
||||||
|
|
||||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
|
||||||
export type ClientConnectionEvent = {
|
|
||||||
readonly type: "client.connection"
|
|
||||||
readonly created: number
|
|
||||||
readonly data: {
|
|
||||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
|
||||||
readonly attempt: number
|
|
||||||
readonly error?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ManagedService = {
|
type ManagedService = {
|
||||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||||
restart: () => Promise<void>
|
restart: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||||
const connectTimeout = 2_000
|
|
||||||
const connectionHistoryLimit = 50
|
|
||||||
const eventFlushInterval = 10
|
|
||||||
|
|
||||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||||
name: "Client",
|
name: "Client",
|
||||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||||
const log = useLog({ component: "client" })
|
const log = useLog({ component: "client" })
|
||||||
const abort = new AbortController()
|
const service = props.service
|
||||||
const history: ClientConnectionEvent[] = []
|
|
||||||
let api = props.api
|
|
||||||
const events = createGlobalEmitter<ClientEventMap>()
|
const events = createGlobalEmitter<ClientEventMap>()
|
||||||
let pending: OpenCodeEvent[] = []
|
let api = props.api
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const [connection, setConnection] = createStore<{
|
|
||||||
status: ClientConnectionStatus
|
|
||||||
attempt: number
|
|
||||||
error?: string
|
|
||||||
}>({
|
|
||||||
status: "connecting",
|
|
||||||
attempt: 0,
|
|
||||||
})
|
|
||||||
let stream: AbortController | undefined
|
|
||||||
|
|
||||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
const connection = createClientConnection(api, {
|
||||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
reconnect: service
|
||||||
if (history.length > connectionHistoryLimit) history.shift()
|
? async (signal) => {
|
||||||
|
api = (await service.reconnect(signal)).api
|
||||||
|
return api
|
||||||
}
|
}
|
||||||
|
: undefined,
|
||||||
function flushEvents() {
|
onEvent(event) {
|
||||||
flushTimer = undefined
|
events.emit(event.type, event)
|
||||||
const queued = pending
|
},
|
||||||
pending = []
|
log,
|
||||||
batch(() => queued.forEach((event) => events.emit(event.type, event)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function emit(event: OpenCodeEvent) {
|
|
||||||
pending.push(event)
|
|
||||||
if (flushTimer) return
|
|
||||||
flushTimer = setTimeout(flushEvents, eventFlushInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect(signal: AbortSignal, attempt: number) {
|
|
||||||
let connectedAt: number | undefined
|
|
||||||
|
|
||||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
|
||||||
const request = new AbortController()
|
|
||||||
const cancel = () => request.abort(signal.reason)
|
|
||||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
|
||||||
signal.addEventListener("abort", cancel, { once: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Open the event stream and validate its initial handshake.
|
|
||||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
|
||||||
log.info("event stream connecting", { attempt })
|
|
||||||
|
|
||||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
|
||||||
const first = await iterator.next()
|
|
||||||
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (first.done) {
|
|
||||||
const error =
|
|
||||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
|
||||||
return { error, connectedAt }
|
|
||||||
}
|
|
||||||
if (first.value.type !== "server.connected")
|
|
||||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
|
||||||
|
|
||||||
// Publish the connected state before forwarding live events.
|
|
||||||
clearTimeout(timeout)
|
|
||||||
record("connected", attempt)
|
|
||||||
connectedAt = Date.now()
|
|
||||||
log.info("event stream connected")
|
|
||||||
emit(first.value)
|
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
||||||
|
|
||||||
// Forward events until the stream closes or this connection is cancelled.
|
|
||||||
while (!signal.aborted) {
|
|
||||||
const event = await iterator.next()
|
|
||||||
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
|
||||||
|
|
||||||
if ("durable" in event.value)
|
|
||||||
log.debug("event", {
|
|
||||||
type: event.value.type,
|
|
||||||
aggregateID: event.value.durable.aggregateID,
|
|
||||||
seq: event.value.durable.seq,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
emit(event.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: undefined, connectedAt }
|
|
||||||
} catch (error) {
|
|
||||||
return { error, connectedAt }
|
|
||||||
} finally {
|
|
||||||
request.abort()
|
|
||||||
clearTimeout(timeout)
|
|
||||||
signal.removeEventListener("abort", cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
stream?.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
stream = controller
|
|
||||||
void (async () => {
|
|
||||||
let attempt = 0
|
|
||||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
|
||||||
const result = await connect(controller.signal, attempt)
|
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return
|
|
||||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
|
|
||||||
attempt += 1
|
|
||||||
const message = errorMessage(result.error)
|
|
||||||
record("disconnected", attempt, message)
|
|
||||||
log.info("event stream disconnected", {
|
|
||||||
attempt,
|
|
||||||
error: message,
|
|
||||||
})
|
|
||||||
setConnection({ status: "reconnecting", attempt, error: message })
|
|
||||||
// Re-resolve the transport before retrying: the server may have
|
|
||||||
// moved (service restarted on a new port) or need starting. Static
|
|
||||||
// transports (--server, standalone) resolve to the same address.
|
|
||||||
if (props.service) {
|
|
||||||
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
|
||||||
if (!controller.signal.aborted)
|
|
||||||
log.info("server resolution failed", {
|
|
||||||
attempt,
|
|
||||||
error: errorMessage(error),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return
|
|
||||||
if (next) {
|
|
||||||
api = next.api
|
|
||||||
if (attempt === 1) continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await wait(1_000, controller.signal)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(start)
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
|
||||||
stream?.abort()
|
|
||||||
if (flushTimer) clearTimeout(flushTimer)
|
|
||||||
pending = []
|
|
||||||
events.clear()
|
events.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,35 +45,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||||||
on: events.on,
|
on: events.on,
|
||||||
listen: events.listen,
|
listen: events.listen,
|
||||||
},
|
},
|
||||||
connection: {
|
connection,
|
||||||
status() {
|
restart: service?.restart,
|
||||||
return connection.status
|
|
||||||
},
|
|
||||||
attempt() {
|
|
||||||
return connection.attempt
|
|
||||||
},
|
|
||||||
error() {
|
|
||||||
return connection.error
|
|
||||||
},
|
|
||||||
internal: {
|
|
||||||
history() {
|
|
||||||
return history.slice()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
restart: props.service?.restart,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1873,15 +1873,14 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
|||||||
|
|
||||||
test("refreshes agents after agent updates", async () => {
|
test("refreshes agents after agent updates", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let requests = 0
|
let agentID = "build"
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/agent") return
|
if (url.pathname !== "/api/agent") return
|
||||||
requests++
|
|
||||||
return json({
|
return json({
|
||||||
location: { directory, project: { id: "proj_test", directory } },
|
location: { directory, project: { id: "proj_test", directory } },
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: requests === 1 ? "build" : "reviewer",
|
id: agentID,
|
||||||
request: { headers: {}, body: {} },
|
request: { headers: {}, body: {} },
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
@@ -1911,6 +1910,11 @@ test("refreshes agents after agent updates", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.location.agent.list()?.[0]?.id === "build")
|
await wait(() => data.location.agent.list()?.[0]?.id === "build")
|
||||||
|
await Bun.sleep(20)
|
||||||
|
events.emit({ id: "evt_agent_unlocated", created: 0, type: "agent.updated", data: {} })
|
||||||
|
await Bun.sleep(20)
|
||||||
|
expect(data.location.agent.list()?.[0]?.id).toBe("build")
|
||||||
|
agentID = "reviewer"
|
||||||
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
|
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
|
||||||
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
|
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2801,7 +2805,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
|||||||
await mounted
|
await mounted
|
||||||
const received: string[] = []
|
const received: string[] = []
|
||||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||||
emitEvent(events, {
|
events.emit({
|
||||||
id: "evt_admitted_1",
|
id: "evt_admitted_1",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "session.inbox.enqueued",
|
type: "session.inbox.enqueued",
|
||||||
|
|||||||
Reference in New Issue
Block a user