Compare commits

...

3 Commits

Author SHA1 Message Date
Brendan Allan 3a2eeebca2 refactor(app): migrate consumers to shared data 2026-08-16 06:12:40 +00:00
Brendan Allan fbbb043d4a refactor: share server data across clients 2026-08-15 11:06:51 +00:00
Brendan Allan a9c3d484ba start on shared data impl 2026-08-14 16:42:58 +08:00
63 changed files with 2474 additions and 2812 deletions
+3
View File
@@ -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": {
@@ -16,7 +16,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
@@ -362,7 +361,6 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void setBack: (handler: () => void) => void
}) { }) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync()
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -386,10 +384,7 @@ function ProviderConnection(props: {
const provider = createMemo(() => ({ const provider = createMemo(() => ({
id: props.provider, id: props.provider,
name: name:
providers.all().get(props.provider)?.name ?? providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
})) }))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
@@ -10,6 +10,7 @@ import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link" import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -42,6 +43,7 @@ export function DialogCustomProvider(props: Props) {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const data = useData()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore<FormState>({
@@ -117,7 +119,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
form, form,
t: language.t, t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [], disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()), existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
}) })
batch(() => { batch(() => {
setForm("err", output.err) setForm("err", output.err)
+5 -1
View File
@@ -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,5 +1,6 @@
import { Component, createMemo, Show } from "solid-js" import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync" import { useData } from "@/context/server"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
@@ -14,12 +15,13 @@ const statusLabels = {
} as const } as const
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const data = useData()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const items = createMemo(() => const items = createMemo(() =>
Object.entries(sync().data.mcp ?? {}) (data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
.map(([name, status]) => ({ name, status: status.status })) .map((server) => ({ name: server.name, status: server.status.status }))
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
) )
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
}} }}
> >
{(i) => { {(i) => {
const mcpStatus = () => sync().data.mcp[i.name] const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
const status = () => mcpStatus()?.status const status = () => mcpStatus()?.status
const statusLabel = () => { const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -1,12 +1,13 @@
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 { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js" import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link" import { ExternalLink } from "./external-link"
@@ -77,30 +78,41 @@ export const ProjectSettingsExtensions: Component = () => {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const directorySDK = useSDK() const directorySDK = useSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const data = useData()
const sync = useSync() const sync = useSync()
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const [serverMcp] = createResource( createEffect(() => {
serverSDK, if (serverSDK.connection.status() !== "connected") return
(sdk) => const ref = { directory: directorySDK().directory }
sdk.api.mcp void Promise.all([
.list() data.location.mcp.server.sync(),
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) data.location.skill.sync(),
.catch(() => ({})), data.location.mcp.server.sync(ref),
{ initialValue: {} }, data.location.skill.sync(ref),
) ]).catch(() => undefined)
})
const globalMcpNames = createMemo(() => const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(), [
...new Set([
...Object.keys(serverSync.data.config.mcp ?? {}),
...(data.location.mcp.server.list() ?? []).map((server) => server.name),
]),
].sort(),
) )
const projectMcpNames = createMemo(() => { const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames()) const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name)) const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort() if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {}) return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
.map((server) => server.name)
.filter((name) => !shared.has(name)) .filter((name) => !shared.has(name))
.sort() .sort()
}) })
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
.status === "connected"
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName)) const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => { const projectPlugins = createMemo(() => {
@@ -108,23 +120,12 @@ export const ProjectSettingsExtensions: Component = () => {
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name)) return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
}) })
const [serverSkills] = createResource( const serverSkills = createMemo(() => data.location.skill.list() ?? [])
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => { const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey)) const shared = new Set(serverSkills().map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item))) return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
(item) => !shared.has(skillKey(item)),
)
}) })
const mcpRows = (items: string[]) => ( const mcpRows = (items: string[]) => (
@@ -205,7 +206,7 @@ export const ProjectSettingsExtensions: Component = () => {
<Show when={projectSkills().length > 0}> <Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard> <ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show> </Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection> <SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div> </div>
</TabsV2.Content> </TabsV2.Content>
@@ -23,6 +23,7 @@ import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useData } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -82,6 +83,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK() const sdk = useSDK()
const data = useData()
const sync = useSync() const sync = useSync()
const files = useFile() const files = useFile()
const layout = useLayout() const layout = useLayout()
@@ -113,8 +115,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path] return [...result, path]
}, []) }, [])
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
const attachments = createMemo(() => const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -246,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
) )
const resources = createMemo(() => const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({ (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`, id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const, kind: "resource" as const,
label: `@${resource.name}`, label: `@${resource.name}`,
@@ -292,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})), })),
]) ])
const slashCommands = createMemo(() => [ const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({ ...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
id: `custom.${item.name}`, id: `custom.${item.name}`,
trigger: item.name, trigger: item.name,
title: item.name, title: item.name,
+6 -4
View File
@@ -27,6 +27,7 @@ import {
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useData } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -115,6 +116,7 @@ const EXAMPLES = [
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const data = useData()
const sync = useSync() const sync = useSync()
const files = useFile() const files = useFile()
@@ -250,8 +252,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths return paths
}) })
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
const imageAttachments = createMemo(() => const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
) )
@@ -585,7 +587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
const mcpResourceList = createMemo(() => const mcpResourceList = createMemo(() =>
Object.values(sync().data.mcp_resource).map( (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map(
(resource): AtOption => ({ (resource): AtOption => ({
type: "resource", type: "resource",
name: resource.name, name: resource.name,
@@ -702,7 +704,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "builtin" as const, type: "builtin" as const,
})) }))
const custom = sync().data.command.map((cmd) => ({ const custom = (data.location.command.list({ directory: sdk().directory }) ?? []).map((cmd) => ({
id: `custom.${cmd.name}`, id: `custom.${cmd.name}`,
trigger: cmd.name, trigger: cmd.name,
title: cmd.name, title: cmd.name,
@@ -286,6 +286,20 @@ beforeAll(async () => {
}, },
})) }))
mock.module("@/context/server", () => ({
useData: () => ({
session: {
remember: () => undefined,
setStatus: () => undefined,
},
location: {
command: {
list: () => commands,
},
},
}),
}))
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
usePlatform: () => ({ usePlatform: () => ({
fetch: fetch, fetch: fetch,
@@ -1,5 +1,6 @@
import type { Message } from "@/types" import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
@@ -7,6 +8,7 @@ import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js" import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local" import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
@@ -37,7 +39,7 @@ export type FollowupDraft = {
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: DirectorySDK["api"]["session"]
serverSync: ServerSync data: Data
sync: DirectorySync sync: DirectorySync
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft draft: FollowupDraft
@@ -54,17 +56,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) input.data.session.setStatus(input.draft.sessionID, "running")
} }
const setIdle = () => { const setIdle = () => {
if (!input.optimisticBusy) return if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) input.data.session.setStatus(input.draft.sessionID, "idle")
} }
const [head, ...tail] = text.split(" ") const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
setBusy() setBusy()
try { try {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
@@ -228,6 +230,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSync = useServerSync() const serverSync = useServerSync()
const data = useData()
const local = useLocal() const local = useLocal()
const permission = usePermission() const permission = usePermission()
const prompt = input.prompt const prompt = input.prompt
@@ -281,6 +284,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
const seed = (target: ServerSync, dir: string, info: SessionInfo) => { const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
data.session.remember(info)
target.session.remember(info) target.session.remember(info)
const [, setStore] = target.child(dir) const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => { setStore("session", (list: SessionInfo[]) => {
@@ -329,6 +333,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const submissionSDK = sdk() const submissionSDK = sdk()
const submissionSync = sync() const submissionSync = sync()
const submissionServerSync = serverSync const submissionServerSync = serverSync
const submissionData = data
const submissionScope = submissionSDK.scope const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory const projectDirectory = submissionSDK.directory
const sessionID = params.id const sessionID = params.id
@@ -503,11 +508,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) { if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ") const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1) const commandName = cmdName.slice(1)
const customCommand = submissionSync.data.command.find((c) => c.name === commandName) const customCommand = submissionData.location.command
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
submissionServerSync.session.set("session_status", session.id, { type: "busy" }) submissionData.session.setStatus(session.id, "running")
void submissionSDK.api.session void submissionSDK.api.session
.command({ .command({
sessionID: session.id, sessionID: session.id,
@@ -524,7 +531,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
), ),
}) })
.catch((err) => { .catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "idle")
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -552,14 +559,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({ void sendFollowupDraft({
api: submissionSDK.api.session, api: submissionSDK.api.session,
sync: submissionSync, sync: submissionSync,
serverSync: submissionServerSync, data: submissionData,
session: () => session, session: () => session,
draft, draft,
messageID, messageID,
optimisticBusy: sessionDirectory === projectDirectory, optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => { }).catch((err) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" }) submissionData.session.setStatus(session.id, "idle")
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1,9 +1,7 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
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, createMemo, createResource, onCleanup } from "solid-js" import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const queryClient = useQueryClient()
const location = () => { const location = () => {
const directory = options.directory() const directory = options.directory()
return directory ? { directory } : undefined return directory ? { directory } : undefined
@@ -118,10 +115,17 @@ export function createProviderConnectionController(options: {
} }
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const directory = options.directory() const ref = location()
await queryClient data.location.integration.invalidate(ref)
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null)) data.location.provider.invalidate(ref)
.catch(() => undefined) data.location.model.invalidate(ref)
data.location.model.invalidateDefault(ref)
await Promise.all([
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
data.location.model.syncDefault(ref),
]).catch(() => undefined)
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
@@ -9,6 +9,7 @@ import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -44,6 +45,7 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const data = useData()
const file = useFile() const file = useFile()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
@@ -62,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
import type { Project } from "@/types" import type { Project } from "@/types"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useSettingsDialog } from "@/components/settings-dialog" import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const openWorkspaces = useSettingsDialog("workspaces") const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project)) const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID) const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory)) const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () => const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory)) directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
@@ -1,6 +1,7 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array" import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
@@ -96,12 +97,13 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const sync = useSync() const sync = useSync()
const data = useData()
const language = useLanguage() const language = useLanguage()
const sdk = useSDK() const sdk = useSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
() => { () => {
@@ -3,6 +3,7 @@ import { DateTime } from "luxon"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
@@ -19,6 +20,7 @@ export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const data = useData()
const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
@@ -37,7 +39,7 @@ export function NewSessionView(props: NewSessionViewProps) {
const label = (value: string) => { const label = (value: string) => {
if (value === MAIN_WORKTREE) { if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main") if (isWorktree()) return language.t("session.new.worktree.main")
const branch = sync().data.vcs?.branch const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main") return language.t("session.new.worktree.main")
} }
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined return undefined
}) })
@@ -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 (
<> <>
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context" import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useServerSync } from "@/context/server-sync"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
const s = server() const s = server()
const id = sessionID() const id = sessionID()
if (!s || !id) return undefined if (!s || !id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory return serverCtx()?.data.session.lineage.peek(id)?.session.location.directory
}) })
return { return {
@@ -14,6 +14,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -49,6 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const data = useData()
const tabs = useTabs() const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -71,13 +73,19 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => { const captureDeleteContext = () => {
const sdk = serverSDK const sdk = serverSDK
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } return {
sdk,
data,
sync: serverSync,
server: ServerConnection.key(sdk.server),
activeDirectory: props.activeDirectory,
}
} }
const loadSessions = async (context = captureDeleteContext()) => { const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" }) const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory( return mergeWorkspaceSessionInventory(
fetched, fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session), context.data.session.list(),
) )
} }
const sessionQuery = useQuery(() => ({ const sessionQuery = useQuery(() => ({
@@ -11,7 +11,7 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server" import { useData, useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -103,6 +103,7 @@ type ServerStatusItem = {
export function StatusPopoverBody(props: { shown: boolean }) { export function StatusPopoverBody(props: { shown: boolean }) {
const sync = useSync() const sync = useSync()
const data = useData()
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -119,8 +120,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
dialogRun += 1 dialogRun += 1
}) })
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? []) const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
+14 -7
View File
@@ -14,7 +14,8 @@ import {
hasServiceNeedingAttention, hasServiceNeedingAttention,
serverStatusDotClass, serverStatusDotClass,
} from "./status-popover-indicator" } from "./status-popover-indicator"
import { useServer } from "@/context/server" import { useData, useServer } from "@/context/server"
import { useSDK } from "@/context/sdk"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -23,17 +24,20 @@ export function StatusPopover() {
const server = useServer() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const data = useData()
const sdk = useSDK()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: (sync().data.lsp ?? []).map((item) => item.status),
}), }),
) )
@@ -87,17 +91,20 @@ export function StatusPopoverV2() {
const server = useServer() const server = useServer()
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const data = useData()
const sdk = useSDK()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
const attention = createMemo(() => const attention = createMemo(() =>
hasServiceNeedingAttention({ hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
}), }),
) )
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status), lsp: (sync().data.lsp ?? []).map((item) => item.status),
}), }),
) )
@@ -84,14 +84,18 @@ function SessionTabEntry(props: {
const tabs = useTabs() const tabs = useTabs()
const language = useLanguage() const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null) const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource( const [loadedSession] = createResource(
() => { () => {
const ctx = props.serverCtx const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null return ctx ? { id: props.tab.sessionId, ctx } : null
}, },
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), ({ id, ctx }) =>
ctx.data.session
.sync(id)
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
@@ -103,13 +107,13 @@ function SessionTabEntry(props: {
const ctx = props.serverCtx const ctx = props.serverCtx
if (!value || !ctx) return if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title }) ctx.data.session.remember({ ...value, title })
try { try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title }) await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) { } catch (err) {
const current = session() const current = session()
const currentCtx = props.serverCtx const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
showToast({ showToast({
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined, description: err instanceof Error ? err.message : undefined,
@@ -126,9 +130,13 @@ function SessionTabEntry(props: {
prefetched = true prefetched = true
createRoot((dispose) => { createRoot((dispose) => {
try { try {
void ctx.sync void Promise.allSettled([
.ensureDirSyncContext(value.location.directory) ctx.data.session.sync(value.id, { children: true }),
.session.sync(value.id) ctx.data.session.pending.sync(value.id),
ctx.data.session.message.sync(value.id),
ctx.data.session.permission.sync(value.id),
ctx.data.session.form.sync(value.id),
])
.catch(() => {}) .catch(() => {})
.finally(dispose) .finally(dispose)
} catch { } catch {
@@ -1,15 +1,7 @@
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 { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { import { loadPathQuery, loadProjectsQuery } 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"
@@ -18,44 +10,11 @@ type WorktreeApi = ServerApi["worktree"]
describe("query keys", () => { describe("query keys", () => {
test("partitions identical directories by server scope", () => { test("partitions identical directories by server scope", () => {
const api = {} as CatalogApi
const location = {} as ServerApi["location"] const location = {} as ServerApi["location"]
const remote = "https://debian.example" as typeof ServerScope.local const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
test("loads the current provider and model catalog", async () => {
const calls: unknown[] = []
const api = {
provider: {
list: async (input: unknown) => {
calls.push(["provider", input])
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
},
},
model: {
list: async (input: unknown) => {
calls.push(["model", input])
return { location: {}, data: [] }
},
default: async (input: unknown) => {
calls.push(["default", input])
return { location: {}, data: null }
},
},
} as unknown as CatalogApi
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
expect(calls).toEqual([
["provider", { location: { directory: "/repo" } }],
["model", { location: { directory: "/repo" } }],
["default", { location: { directory: "/repo" } }],
])
expect(result.connected).toEqual(["openai"])
}) })
test("loads current location metadata", async () => { test("loads current location metadata", async () => {
@@ -73,39 +32,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 +87,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,11 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import type { import type {
AgentListInput,
AgentListOutput,
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,20 +17,16 @@ 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, normalizeProjectInfo } 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 type { ServerScope } from "@/utils/server-scope"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import { sameDirectory } from "@/utils/workspace" import { sameDirectory } from "@/utils/workspace"
type GlobalStore = { type GlobalStore = {
ready: boolean
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
@@ -70,12 +55,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
} }
const providerRev = new Map<string, number>()
export function clearProviderRev(scope: ServerScope, directory: string) {
providerRev.delete(ScopedKey.from(scope, directory))
}
function runAll(list: Array<() => Promise<unknown>>) { function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item())) return Promise.allSettled(list.map((item) => item()))
} }
@@ -110,10 +89,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({
@@ -152,7 +129,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & { serverAPI: {
readonly location: LocationApi readonly location: LocationApi
readonly project: ProjectApi readonly project: ProjectApi
readonly worktree: WorktreeApi readonly worktree: WorktreeApi
@@ -166,7 +143,6 @@ export async function bootstrapGlobal(input: {
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() => () =>
input.queryClient input.queryClient
@@ -226,53 +202,6 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () =>
retry(async () => {
const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location),
sdk.model.list(location),
sdk.model.default(location),
])
return normalizeProviderList(providers.data, models.data, defaultModel.data)
}),
})
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 +215,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: {
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>
@@ -317,49 +233,25 @@ export async function bootstrapDirectory(input: {
config: Config config: Config
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse
} }
queryClient: QueryClient queryClient: QueryClient
session?: ServerSession session?: ServerSession
}) { }) {
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 }))
} }
if (loading) input.setStore("status", "partial") if (loading) input.setStore("status", "partial")
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
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,22 +321,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
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -1,14 +1,13 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js" import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
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 }> = []
let providerQuerySuccess = true
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0], store[0],
store[1], store[1],
@@ -17,12 +16,26 @@ 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 path = { state: "", config: "", worktree: "", directory: "", home: "" }
const data = {
location: {
info: () => undefined,
agent: { list: () => undefined },
command: { list: () => undefined },
reference: { list: () => undefined },
provider: { list: () => undefined },
model: { list: () => undefined, default: () => 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 () => ({}) }),
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }), projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
path: (directory: string | null) => ({ path: (directory: string | null) => ({
queryKey: [directory, "path"], queryKey: [directory, "path"],
queryFn: async () => ({ queryFn: async () => ({
@@ -60,7 +73,7 @@ beforeAll(async () => {
return options().queryKey?.[1] === "path" return options().queryKey?.[1] === "path"
}, },
get isSuccess() { get isSuccess() {
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess return false
}, },
get isRefetchError() { get isRefetchError() {
return false return false
@@ -69,7 +82,6 @@ beforeAll(async () => {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return [] if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
return undefined return undefined
}, },
} }
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -167,7 +181,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -198,7 +213,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -214,7 +230,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 +250,21 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
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(1)
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 +291,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -293,11 +301,8 @@ 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(1)
expect(queries[0]?.().enabled).toBe(false) expect(queries[0]?.().enabled).toBe(false)
expect(queries[3]?.().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 +310,6 @@ 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[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 })
@@ -317,9 +319,8 @@ describe("createChildStoreManager", () => {
} }
}) })
test("does not mark a cancelled provider query as ready", () => { test("does not mark unsynced provider data as ready", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined let manager: ReturnType<typeof createChildStoreManager> | undefined
providerQuerySuccess = false
const dispose = createOwner((owner) => { const dispose = createOwner((owner) => {
manager = createChildStoreManager({ manager = createChildStoreManager({
@@ -334,7 +335,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -343,7 +345,6 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/cancelled") const [store] = manager.child("/cancelled")
expect(store.provider_ready).toBe(false) expect(store.provider_ready).toBe(false)
} finally { } finally {
providerQuerySuccess = true
dispose() dispose()
} }
}) })
@@ -365,7 +366,8 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
global: { provider }, data,
global: { path },
}) })
}) })
@@ -374,11 +376,9 @@ 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)
connected = true connected = true
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
expect(queries[4]?.().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,
@@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync" 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 type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import type { Data } from "@opencode-ai/client/solid"
import { normalizeAgentList, normalizeProviderList } from "./utils"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
@@ -32,8 +33,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: {
provider: NormalizedProviderListResponse path: Path
} }
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -45,7 +47,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 +121,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,59 +186,49 @@ 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(),
})) }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
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,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) return (
input.data.location.provider.list({ directory }) !== undefined &&
input.data.location.model.list({ directory }) !== undefined &&
input.data.location.model.default({ directory }) !== undefined
)
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const provider = input.data.location.provider.list({ directory })
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY const model = input.data.location.model.list({ directory })
const provider = providerQuery.data const defaultModel = input.data.location.model.default({ directory })
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider if (!provider || !model || defaultModel === undefined) return { all: new Map(), connected: [], default: {} }
return provider return normalizeProviderList(provider, model, defaultModel)
}, },
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 +242,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 +263,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 +276,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 +339,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 +355,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) {
+11 -3
View File
@@ -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,9 +100,15 @@ 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({
const permission = createServerPermissionState({ sdk, sync }) api: () => sdk.api,
const notification = createServerNotificationState({ sdk, sync, key: connKey }) event: sdk.event,
connection: sdk.connection,
directory: "",
})
const sync = createServerSyncContext(sdk, data)
const permission = createServerPermissionState({ sdk, sync, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) { function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false }) const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -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,
-1
View File
@@ -3,7 +3,6 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr
import { useLocation } from "@solidjs/router" import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { ServerConnection, useServers } from "./servers" import { ServerConnection, useServers } from "./servers"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import type { Project } from "@/types" import type { Project } from "@/types"
+8 -1
View File
@@ -11,6 +11,8 @@ import { hasCustomAgent, resolveAgent } from "./local-agent"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { useData } from "./server"
import { normalizeAgentList } from "./global-sync/utils"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { ScopedKey, type ServerScope } from "@/utils/server-scope"
@@ -61,13 +63,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const params = useParams() const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const data = useData()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const models = useModels() const models = useModels()
const settings = useSettings() const settings = useSettings()
const id = createMemo(() => params.id || undefined) const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const list = createMemo(() =>
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
(item) => item.mode !== "subagent" && !item.hidden,
),
)
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
+9 -10
View File
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" import type { Data } from "@opencode-ai/client/solid"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) {
return index return index
} }
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) { export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
@@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
}) })
} }
const lookup = async (directory: string, sessionID?: string) => { const lookup = async (sessionID?: string) => {
if (!sessionID) return undefined if (!sessionID) return undefined
const sync = input.sync.ensureDirSyncContext(directory) const session = input.data.session.get(sessionID)
const session = sync.session.get(sessionID)
if (session) return session if (session) return session
return sync.session return input.data.session
.sync(sessionID) .sync(sessionID)
.then(() => sync.session.get(sessionID)) .then(() => input.data.session.get(sessionID))
.catch(() => undefined) .catch(() => undefined)
} }
@@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => { const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => { void lookup(sessionID).then((session) => {
if (meta.disposed) return if (meta.disposed) return
if (!session) return if (!session) return
if (session.parentID) return if (session.parentID) return
@@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
time: number, time: number,
) => { ) => {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => { void lookup(sessionID).then((session) => {
if (meta.disposed) return if (meta.disposed) return
if (session?.parentID) return if (session?.parentID) return
@@ -278,7 +277,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" &&
+9 -10
View File
@@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/client/promise"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk" import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync" import type { ServerSync } from "./server-sync"
import type { Data } from "@opencode-ai/client/solid"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global" import { useGlobal } from "./global"
@@ -52,9 +53,9 @@ 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; data: Data }) {
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ {
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]), ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
@@ -141,10 +142,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
}) })
} }
function sessions(directory?: string) { function sessions(_directory?: string) {
const info = Object.values(input.sync.session.data.info).filter((session) => !!session) return input.data.session.list()
if (!directory) return info
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
} }
function isAutoAccepting(sessionID: string, directory?: string) { function isAutoAccepting(sessionID: string, directory?: string) {
@@ -160,15 +159,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
} }
function isPending(permission: PermissionRequest) { function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID] const pending = input.data.session.permission.list(permission.sessionID)
return pending === undefined || pending.some((item) => item.id === permission.id) return pending === undefined || pending.some((item) => item.id === permission.id)
} }
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory) const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
if (override !== undefined) return override if (override !== undefined) return override
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) if (input.data.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) const lineage = await input.data.session.lineage.resolve(permission.sessionID).catch(() => undefined)
if (meta.disposed || !lineage) return false if (meta.disposed || !lineage) return false
return shouldAutoRespond(permission, directory) return shouldAutoRespond(permission, directory)
} }
@@ -197,7 +196,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 -67
View File
@@ -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"])
})
})
+32 -283
View File
@@ -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 -59
View File
@@ -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
+43 -178
View File
@@ -4,20 +4,12 @@ import { getFilename } from "@opencode-ai/core/util/path"
import { batch, getOwner, onCleanup, untrack } from "solid-js" import { batch, getOwner, onCleanup, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { type ServerEvent, type ServerSDK } from "./server-sdk" import { type ServerEvent, type ServerSDK } from "./server-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
clearProviderRev,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery, loadGlobalConfigQuery,
loadIntegrationsQuery,
loadPathQuery, loadPathQuery,
loadProjectsQuery,
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"
@@ -32,7 +24,6 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { pathKey, PathKey } from "@/utils/path-key" import { pathKey, PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync" import { createDirSyncContext } from "./directory-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount" import { createRefCountMap } from "@/utils/refcount"
import { ServerConnection } from "./servers" import { ServerConnection } from "./servers"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
@@ -40,21 +31,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
@@ -68,11 +53,8 @@ export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
} }
type GlobalStore = { type GlobalStore = {
ready: boolean
error?: InitError
path: Path path: Path
project: Project[] project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
@@ -88,16 +70,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 +79,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 +125,14 @@ 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),
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()
@@ -222,22 +146,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const session = createServerSession(serverSDK.api.session, serverSDK.api.message) const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
const connected = () => serverSDK.connection.status() === "connected" const connected = () => serverSDK.connection.status() === "connected"
const hydrateSessionState = async (sessionID: string) => { const hydrateSession = (sessionID: string) => session.sync(sessionID)
await session.hydrateTransient(sessionID, async () => {
const [pending, forms] = await Promise.all([
serverSDK.api.session.inbox.list({ sessionID }),
serverSDK.api.form.list({ sessionID }),
])
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [
{ ...queryOptionsApi.globalConfig(), enabled: connected() }, { ...queryOptionsApi.globalConfig(), enabled: connected() },
{ ...queryOptionsApi.providers(null), enabled: connected() }, { ...queryOptionsApi.path(), enabled: connected() },
{ ...queryOptionsApi.path(null), enabled: connected() },
], ],
})) }))
const activeSessionsQuery = useQuery(() => ({ const activeSessionsQuery = useQuery(() => ({
@@ -246,7 +160,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const active = await serverSDK.api.session.active() const active = await serverSDK.api.session.active()
reconcileActiveSessionStatuses(session, active) reconcileActiveSessionStatuses(session, active)
Object.keys(active).forEach((sessionID) => { Object.keys(active).forEach((sessionID) => {
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined) void session.resolve(sessionID).catch(() => undefined)
}) })
return active return active
}, },
@@ -255,9 +169,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})) }))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
get ready() {
return bootstrap.isSuccess
},
project: [], project: [],
provider_auth: {}, provider_auth: {},
get path() { get path() {
@@ -265,11 +176,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (pathQuery.isLoading) return EMPTY if (pathQuery.isLoading) return EMPTY
return pathQuery.data ?? EMPTY return pathQuery.data ?? EMPTY
}, },
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
return providerQuery.data ?? EMPTY
},
get config() { get config() {
if (configQuery.isLoading) return {} if (configQuery.isLoading) return {}
return configQuery.data ?? {} return configQuery.data ?? {}
@@ -338,51 +244,33 @@ 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 }),
showToast({ data.location.mcp.resource.sync({ directory }),
variant: "error", ]).catch((err) => {
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), showToast({
description: formatServerError(err, language.t), variant: "error",
}) title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
description: formatServerError(err, language.t),
}) })
})
}, },
onDispose: (directory) => { onDispose: (directory) => {
const key = directoryKey(directory) const key = directoryKey(directory)
queue.clear(key) queue.clear(key)
sessionMeta.delete(key) sessionMeta.delete(key)
clearProviderRev(serverSDK.scope, key)
}, },
translate: language.t, translate: language.t,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
data,
global: { global: {
get provider() { get path() {
return globalStore.provider return globalStore.path
}, },
}, },
}) })
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: () => {
@@ -393,14 +281,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
}, },
connected: (info) => { connected: (info) => {
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined) if (info.reconnect) void session.refreshPinned(() => Promise.resolve()).catch(() => undefined)
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch() if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch() if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
Object.keys(children.children) Object.keys(children.children)
.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 +386,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,
@@ -508,7 +396,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
config: globalStore.config, config: globalStore.config,
path: globalStore.path, path: globalStore.path,
project: globalStore.project, project: globalStore.project,
provider: globalStore.provider,
}, },
api: serverSDK.api, api: serverSDK.api,
store: child[0], store: child[0],
@@ -518,7 +405,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryClient, queryClient,
session, session,
}), }),
initial ? refreshVcs(directory) : Promise.resolve(),
]) ])
}) })
@@ -568,7 +454,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,7 +514,6 @@ 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 (directory === "global") { if (directory === "global") {
@@ -661,21 +546,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 +568,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)
}, },
}) })
}) })
@@ -710,7 +584,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}) })
const projectApi = { const projectApi = {
loadSessions,
meta(directory: string, patch: ProjectMeta) { meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch) children.projectMeta(directory, patch)
}, },
@@ -727,29 +600,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}, },
onSuccess: () => { onSuccess: () => {
bootstrap.refetch() bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers data.location.provider.invalidate()
// appear immediately in the available provider list across all directories. data.location.model.invalidate()
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) data.location.model.invalidateDefault()
queryClient.invalidateQueries({ void Promise.all([
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", data.location.provider.sync(),
}) data.location.model.sync(),
data.location.model.syncDefault(),
])
}, },
})) }))
return { return {
data: globalStore, data: globalStore,
set, set,
get ready() {
return globalStore.ready
},
get error() {
return globalStore.error
},
child: children.child, child: children.child,
peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders: catalog.refreshActive,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
@@ -788,8 +654,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 +667,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),
@@ -814,8 +684,3 @@ export const useServerSync = () => {
const server = useServer() const server = useServer()
return server.ctx.sync return server.ctx.sync
} }
export function useQueryOptions() {
const sync = useServerSync()
return sync.queryOptions
}
+5
View File
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
}, },
}) })
export const useData = () => {
const server = useServer()
return server.ctx.data
}
+8 -11
View File
@@ -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) ?? [],
} }
} }
+34 -9
View File
@@ -1,9 +1,9 @@
import { useQueryOptions } from "@/context/server-sync" import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProviderList } from "@/context/global-sync/utils"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import { type Accessor } from "solid-js" import { createEffect, createMemo, type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog" import { emptyProviderCatalog } from "./provider-catalog"
import { useQuery } from "@tanstack/solid-query"
import { pathKey } from "@/utils/path-key"
export const popularProviders = [ export const popularProviders = [
"opencode", "opencode",
@@ -18,16 +18,41 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders) const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) { export function useProviders(directory: Accessor<string | undefined>) {
const providersQuery = useQuery(() => { const data = useData()
const queryOpts = useQueryOptions() const sdk = useServerSDK()
const location = () => {
const dir = directory() const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null) return dir ? { directory: dir } : undefined
}
createEffect(() => {
if (sdk.connection.status() !== "connected") return
const ref = location()
void Promise.all([
data.location.provider.sync(ref),
data.location.model.sync(ref),
data.location.model.syncDefault(ref),
]).catch(() => undefined)
}) })
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data) const providers = createMemo(() => {
const ref = location()
const provider = data.location.provider.list(ref)
const model = data.location.model.list(ref)
const defaultModel = data.location.model.default(ref)
if (!provider || !model || defaultModel === undefined) return emptyProviderCatalog
return normalizeProviderList(provider, model, defaultModel)
})
return { return {
ready: () => providersQuery.isSuccess, ready: () => {
const ref = location()
return (
data.location.provider.list(ref) !== undefined &&
data.location.model.list(ref) !== undefined &&
data.location.model.default(ref) !== undefined
)
},
all: () => providers().all, all: () => providers().all,
default: () => providers().default, default: () => providers().default,
popular: () => popular: () =>
+20 -2
View File
@@ -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()))
@@ -33,8 +37,7 @@ export function DirectoryDataProvider(
return `/${slug()}/session/${sessionID}` return `/${slug()}/session/${sessionID}`
} }
const navigateToSession = async (sessionID: string) => { const navigateToSession = async (sessionID: string) => {
const session = serverSync.session await data.session.lineage.resolve(sessionID).catch(() => undefined)
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
navigate(href(sessionID)) navigate(href(sessionID))
} }
@@ -52,6 +55,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
@@ -112,7 +112,7 @@ export function createHomeSessionsController(home: HomeController) {
const key = `${ServerConnection.key(conn)}\0${record.session.id}` const key = `${ServerConnection.key(conn)}\0${record.session.id}`
if (prefetched.has(key)) return if (prefetched.has(key)) return
prefetched.add(key) prefetched.add(key)
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {}) void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {})
}) })
}) })
@@ -179,7 +179,7 @@ export function createHomeSessionsController(home: HomeController) {
const directory = project?.worktree ?? session.location.directory const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext() const ctx = home.server.focusedContext()
if (!ctx) return if (!ctx) return
ctx.sync.session.remember(session) ctx.data.session.remember(session)
ctx.projects.open(directory) ctx.projects.open(directory)
if (options?.background) { if (options?.background) {
tabs.addSessionTab({ server: connKey, sessionId: session.id }) tabs.addSessionTab({ server: connKey, sessionId: session.id })
@@ -11,32 +11,28 @@ export function useSessionTabAvatarState(
const global = useGlobal() const global = useGlobal()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const serverCtx = useServerCtx(connection) const serverCtx = useServerCtx(connection)
const sync = () => serverCtx()?.sync
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
const ctx = serverCtx() const ctx = serverCtx()
if (!ctx) return false if (!ctx) return false
const sync = ctx.sync
const permission = ctx.permission const permission = ctx.permission
const [store] = sync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory()) return !permission.autoResponds(item, directory())
}) })
}) })
const hasQuestions = createMemo(() => { const hasQuestions = createMemo(() => {
const serverSync = sync() const data = serverCtx()?.data
if (!serverSync) return false if (!data) return false
const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
}) })
const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo( const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0, () => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
) )
const loading = createMemo(() => { const loading = createMemo(() => {
const serverSync = sync() const data = serverCtx()?.data
if (!serverSync) return false if (!data) return false
if (needsAttention()) return false if (needsAttention()) return false
return serverSync.session.data.session_working(sessionId()) return data.session.status(sessionId()) === "running"
}) })
return { unread, loading } return { unread, loading }
} }
@@ -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({
@@ -17,7 +17,6 @@ import {
import { StatusPopoverV2 } from "@/components/status-popover" import { StatusPopoverV2 } from "@/components/status-popover"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSync } from "@/context/server-sync"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -107,7 +106,6 @@ function ProviderTip() {
const language = useLanguage() const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const serverSync = useServerSync()
const providers = useProviders(() => sdk().directory) const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted( const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"), Persist.global("new-session.provider-tip"),
@@ -115,7 +113,7 @@ function ProviderTip() {
) )
const visible = createMemo( const visible = createMemo(
() => () =>
serverSync.child(sdk().directory)[0].provider_ready && providers.ready() &&
persistedReady() && persistedReady() &&
providers.paid().length === 0 && providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
@@ -1,7 +1,7 @@
import { createMemo } from "solid-js" import { createEffect, createMemo } from "solid-js"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { import {
@@ -47,7 +47,7 @@ export function createNewSessionWorkspaceController(input: {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const data = useData()
const settings = useSettings() const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git") const visible = createMemo(() => sync().project?.vcs === "git")
const selected = createMemo(() => { const selected = createMemo(() => {
@@ -74,12 +74,17 @@ export function createNewSessionWorkspaceController(input: {
}), }),
) )
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch) createEffect(() => {
const project = sync().project
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
const branch = createMemo(() => const branch = createMemo(() =>
resolveNewSessionBranch({ resolveNewSessionBranch({
worktree: value(), worktree: value(),
local: localBranch(), local: localBranch(),
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch, worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}), }),
) )
const remember = (worktree = value()) => { const remember = (worktree = value()) => {
+28 -19
View File
@@ -47,6 +47,7 @@ import { CommentsProvider, useComments } from "@/context/comments"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { DirectoryDataProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
@@ -101,7 +102,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"
@@ -151,8 +152,8 @@ async function runPromptRollbackMutation<T, R>(input: {
// workspace-scoped state (terminal, directory providers) lives below. // workspace-scoped state (terminal, directory providers) lives below.
export function TargetSessionRouteContent() { export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync() const data = useData()
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory) const directory = createMemo(() => data.session.lineage.peek(params.id)?.session.location.directory)
return ( return (
// Settings must keep the target-server SDK, sync, and models context and remain registered // Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary. // when session content falls back to the route error boundary.
@@ -238,11 +239,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
function ResolvedTargetSessionRoute() { function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const tabs = useTabs() const tabs = useTabs()
const sync = useServerSync() const data = useData()
const serverKey = createMemo(() => requireServerKey(params.serverKey)) const serverKey = createMemo(() => requireServerKey(params.serverKey))
const current = createSessionLineage( const current = createSessionLineage(
() => params.id, () => params.id,
() => sync.session.lineage, () => data.session.lineage,
) )
const directory = createMemo(() => current()?.session.location.directory) const directory = createMemo(() => current()?.session.location.directory)
@@ -330,6 +331,7 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
export default function Page() { export default function Page() {
const serverSync = useServerSync() const serverSync = useServerSync()
const data = useData()
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const file = useFile() const file = useFile()
@@ -384,7 +386,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(() =>
@@ -572,12 +573,17 @@ export default function Page() {
const project = sync().project const project = sync().project
return !!project && project.vcs !== "git" return !!project && project.vcs !== "git"
}) })
const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory }))
const changesOptions = createMemo<ChangeMode[]>(() => { const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = [] const list: ChangeMode[] = []
const project = sync().project const project = sync().project
const vcs = sync().data.vcs
if (project?.vcs === "git") list.push("git") if (project?.vcs === "git") list.push("git")
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { if (
project?.vcs === "git" &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
) {
list.push("branch") list.push("branch")
} }
list.push("turn") list.push("turn")
@@ -601,8 +607,8 @@ export default function Page() {
serverSDK.scope, serverSDK.scope,
"session-vcs", "session-vcs",
sdk().directory, sdk().directory,
sync().data.vcs?.branch ?? "", vcs()?.branch.current ?? "",
sync().data.vcs?.default_branch ?? "", vcs()?.branch.default ?? "",
] as const, ] as const,
) )
const vcsQuery = createQuery(() => { const vcsQuery = createQuery(() => {
@@ -849,7 +855,7 @@ export default function Page() {
return [ return [
sdk().directory, sdk().directory,
id, id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", id ? data.session.status(id) : "idle",
id ? composer.blocked() : false, id ? composer.blocked() : false,
] as const ] as const
}, },
@@ -1034,7 +1040,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type, () => data.session.status(controller.identity.params.id ?? ""),
(next, prev) => { (next, prev) => {
if (next !== "idle" || prev === undefined || prev === "idle") return if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs() refreshVcs()
@@ -1646,7 +1652,7 @@ export default function Page() {
target.session.remember({ ...session, revert: next }) target.session.remember({ ...session, revert: next })
} }
const busy = (sessionID: string) => sync().data.session_working(sessionID) const busy = (sessionID: string) => data.session.status(sessionID) === "running"
const queuedFollowups = createMemo(() => { const queuedFollowups = createMemo(() => {
const id = controller.identity.params.id const id = controller.identity.params.id
if (!id) return emptyFollowups if (!id) return emptyFollowups
@@ -1677,8 +1683,8 @@ export default function Page() {
const ok = await sendFollowupDraft({ const ok = await sendFollowupDraft({
api: sdk().api.session, api: sdk().api.session,
sync: sync(), sync: sync(),
serverSync: serverSync, data,
session: () => sync().session.get(input.sessionID), session: () => data.session.get(input.sessionID),
draft: item, draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory, optimisticBusy: item.sessionDirectory === sdk().directory,
}).catch((err) => { }).catch((err) => {
@@ -1743,7 +1749,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync().session.get(sessionID)?.parentID) return Promise.resolve() if (data.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve() if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve()
@@ -2128,9 +2134,12 @@ export default function Page() {
const id = controller.data.parentID() const id = controller.data.parentID()
if (!id) return if (!id) return
navigate( navigate(
controller.identity.params.serverKey sessionHref(
? sessionHref(requireServerKey(controller.identity.params.serverKey), id) controller.identity.params.serverKey
: legacySessionHref(sdk().directory, id), ? requireServerKey(controller.identity.params.serverKey)
: 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,43 +7,40 @@ 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 { 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"
import { normalizeAgentList } from "@/context/global-sync/utils"
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 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 {
agents: { agents: {
available: sync().data.agent, available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
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(),
@@ -4,6 +4,7 @@ import { type Accessor, createEffect, createMemo, createResource, onCleanup } fr
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { PromptInputState } from "@/components/prompt-input" import type { PromptInputState } from "@/components/prompt-input"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import type { SessionComposerController } from "./session-composer-state" import type { SessionComposerController } from "./session-composer-state"
@@ -40,6 +41,7 @@ export function createSessionComposerRegionController(input: {
setDockRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void
}) { }) {
const sync = useSync() const sync = useSync()
const data = useData()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
ready: input.ready() || input.state.dock(), ready: input.ready() || input.state.dock(),
height: 320, height: 320,
@@ -104,7 +106,7 @@ export function createSessionComposerRegionController(input: {
const parentID = createMemo(() => { const parentID = createMemo(() => {
const id = input.sessionID() const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined return id ? data.session.get(id)?.parentID : undefined
}) })
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing()) const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
const progress = useSpring( const progress = useSpring(
@@ -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,36 +34,20 @@ 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(data.session.list(), data.session.form.list, params.id)
}) })
const permissionRequest = createMemo((): PermissionRequest | undefined => { const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => { return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory) return !permission.autoResponds(item, sdk().directory)
}) })
}) })
@@ -84,16 +68,16 @@ export function createSessionComposerController(options?: { closeMs?: number | (
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
) )
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) const live = createMemo(() => data.session.status(params.id ?? "") === "running" || blocked())
const primary = () => { const primary = () => {
const id = params.id const id = params.id
return !!id && !serverSync.session.get(id)?.parentID return !!id && !data.session.get(id)?.parentID
} }
const backgroundBlocking = createMemo(() => { const backgroundBlocking = createMemo(() => {
if (!primary()) return [] if (!primary()) return []
const id = params.id const id = params.id
if (!id) return [] if (!id) return []
const assistant = (serverSync.session.data.session_message[id] ?? []).findLast( const assistant = data.session.message.list(id).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined, (message) => message.type === "assistant" && message.time.completed === undefined,
) )
if (assistant?.type !== "assistant") return [] if (assistant?.type !== "assistant") return []
@@ -116,7 +100,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const id = params.id const id = params.id
if (!id) return [] if (!id) return []
const blocking = backgroundBlocking() const blocking = backgroundBlocking()
const messages = serverSync.session.data.session_message[id] ?? [] const messages = data.session.message.list(id)
const completed = new Set( const completed = new Set(
messages.flatMap((message) => { messages.flatMap((message) => {
if (message.type !== "synthetic") return [] if (message.type !== "synthetic") return []
@@ -144,9 +128,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
] ]
}) })
}) })
const active = Object.values(serverSync.session.data.info).flatMap((info) => { const active = data.session.list().flatMap((info) => {
if (info?.parentID !== id) return [] if (info?.parentID !== id) return []
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return [] if (data.session.status(info.id) === "idle") return []
if ( if (
blocking.some( blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)), (item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
@@ -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(
@@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie
function sessionTreeRequest<T>( function sessionTreeRequest<T>(
session: SessionInfo[], session: SessionInfo[],
request: Record<string, T[] | undefined>, request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
sessionID?: string, sessionID?: string,
include: (item: T) => boolean = () => true, include: (item: T) => boolean = () => true,
) { ) {
@@ -28,14 +28,15 @@ function sessionTreeRequest<T>(
} }
} }
const id = ids.find((id) => request[id]?.some(include)) const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
const id = ids.find((id) => list(id)?.some(include))
if (!id) return if (!id) return
return request[id]?.find(include) return list(id)?.find(include)
} }
export function sessionPermissionRequest( export function sessionPermissionRequest(
session: SessionInfo[], session: SessionInfo[],
request: Record<string, PermissionRequest[] | undefined>, request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
sessionID?: string, sessionID?: string,
include?: (item: PermissionRequest) => boolean, include?: (item: PermissionRequest) => boolean,
) { ) {
@@ -44,7 +45,7 @@ export function sessionPermissionRequest(
export function sessionQuestionForm( export function sessionQuestionForm(
session: SessionInfo[], session: SessionInfo[],
request: Record<string, FormInfo[] | undefined>, request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string, sessionID?: string,
) { ) {
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question") return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
@@ -2,6 +2,7 @@ import type { Message, UserMessage } from "@/types"
import { createMemo, type Accessor } from "solid-js" import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createSessionTabs } from "./helpers" import { createSessionTabs } from "./helpers"
import { import {
@@ -24,16 +25,17 @@ export function createSessionController(input: {
}) { }) {
const file = useFile() const file = useFile()
const sync = useSync() const sync = useSync()
const data = useData()
const layout = useSessionLayout() const layout = useSessionLayout()
const sessionID = createMemo(() => layout.params.id) const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => { const info = createMemo(() => {
const id = sessionID() const id = sessionID()
return id ? sync().session.get(id) : undefined return id ? data.session.get(id) : undefined
}) })
const parentID = createMemo(() => info()?.parentID) const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => { const parent = createMemo(() => {
const id = parentID() const id = parentID()
return id ? sync().session.get(id) : undefined return id ? data.session.get(id) : undefined
}) })
const status = createMemo(() => { const status = createMemo(() => {
const id = sessionID() const id = sessionID()
@@ -75,7 +77,7 @@ export function createSessionController(input: {
status, status,
working: createMemo(() => { working: createMemo(() => {
const id = sessionID() const id = sessionID()
return id ? sync().data.session_working(id) : false return id ? data.session.status(id) === "running" : false
}), }),
revertMessageID, revertMessageID,
}, },
@@ -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,8 +48,10 @@ 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 data = server.ctx.data
const settings = useSettings() const settings = useSettings()
const tabs = useTabs() const tabs = useTabs()
const dialog = useDialog() const dialog = useDialog()
@@ -60,7 +64,7 @@ export function createTimelineController(input: {
const boundary = input.session.history const boundary = input.session.history
.messages() .messages()
.find((message) => message.role === "user" && !visible.has(message.id))?.id .find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = sync().data.session_message[id] ?? [] const projected = data.session.message.list(id)
if (!boundary) return projected if (!boundary) return projected
const index = projected.findIndex((message) => message.id === boundary) const index = projected.findIndex((message) => message.id === boundary)
return index < 0 ? projected : projected.slice(0, index) return index < 0 ? projected : projected.slice(0, index)
@@ -146,19 +150,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) =>
input.session.identity.params.serverKey sessionHref(
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id) input.session.identity.params.serverKey
: legacySessionHref(sdk().directory, id) ? requireServerKey(input.session.identity.params.serverKey)
: 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: input.session.identity.params.serverKey
server: requireServerKey(input.session.identity.params.serverKey), ? requireServerKey(input.session.identity.params.serverKey)
directory: sdk().directory, : ServerConnection.key(serverSDK.server),
}) directory: sdk().directory,
navigate(`/${input.session.identity.params.dir}/session`) })
} }
const exportSession = async (id: string) => { const exportSession = async (id: string) => {
try { try {
@@ -180,7 +187,7 @@ export function createTimelineController(input: {
} }
} }
const remove = async (id: string) => { const remove = async (id: string) => {
const session = sync().session.get(id) const session = data.session.get(id)
if (!session) return false if (!session) return false
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived) const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id) const index = sessions.findIndex((item) => item.id === id)
@@ -203,7 +210,7 @@ export function createTimelineController(input: {
function DeleteDialog(props: { sessionID: string }) { function DeleteDialog(props: { sessionID: string }) {
const name = createMemo( const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), () => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
) )
const confirm = async () => { const confirm = async () => {
await remove(props.sessionID) await remove(props.sessionID)
@@ -254,7 +261,7 @@ export function createTimelineController(input: {
() => [input.session.data.parentID(), childTaskDescription()] as const, () => [input.session.data.parentID(), childTaskDescription()] as const,
([id, description]) => { ([id, description]) => {
if (!id || description || sync().data.message[id] !== undefined) return if (!id || description || sync().data.message[id] !== undefined) return
void sync().session.sync(id) void Promise.all([data.session.sync(id), sync().session.sync(id)])
}, },
{ defer: true }, { defer: true },
), ),
@@ -51,7 +51,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSync } from "@/context/server-sync" import { useData } from "@/context/server"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { scheduleConnectedMeasure } from "./measure" import { scheduleConnectedMeasure } from "./measure"
@@ -414,7 +414,7 @@ function MessageTimelineView(
) { ) {
let touchGesture: number | undefined let touchGesture: number | undefined
const language = useLanguage() const language = useLanguage()
const serverSync = useServerSync() const data = useData()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom) const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
@@ -438,6 +438,11 @@ function MessageTimelineView(
const projection = props.data.projection const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory) const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory())) const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
createEffect(() => {
const directory = sync().project?.worktree
if (!directory) return
void data.location.vcs.sync({ directory }).catch(() => undefined)
})
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false) const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
const [summaryOpen, setSummaryOpen] = createSignal(false) const [summaryOpen, setSummaryOpen] = createSignal(false)
const setSummary = (open: boolean) => { const setSummary = (open: boolean) => {
@@ -1468,8 +1473,8 @@ function MessageTimelineView(
project={project()} project={project()}
directory={sessionDirectory()} directory={sessionDirectory()}
local={!workspaceSession()} local={!workspaceSession()}
branch={sync().data.vcs?.branch} branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={serverSync.child(project().worktree)[0].vcs?.branch} baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()} diffs={sessionDiffs()}
sessionID={id} sessionID={id}
moveEligible={props.workspaceMoveEligible} moveEligible={props.workspaceMoveEligible}
@@ -10,6 +10,7 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
@@ -54,6 +55,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sdk = useSDK() const sdk = useSDK()
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const data = useData()
const terminal = useTerminal() const terminal = useTerminal()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() const navigate = useNavigate()
@@ -304,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!message) return if (!message) return
const parts = sync().data.part[message.id] const parts = sync().data.part[message.id]
if (sync().data.session_working(sessionID)) { if (data.session.status(sessionID) === "running") {
await session.interrupt({ sessionID }).catch(() => {}) await session.interrupt({ sessionID }).catch(() => {})
} }
+8 -2
View File
@@ -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:"
} }
} }
+269
View File
@@ -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
+2
View File
@@ -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"])
})
})
+1 -1
View File
@@ -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")
+17 -182
View File
@@ -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
function flushEvents() {
flushTimer = undefined
const queued = pending
pending = []
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) : undefined,
} onEvent(event) {
})() events.emit(event.type, event)
} },
log,
})
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
+8 -4
View File
@@ -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",
+5
View File
@@ -154,6 +154,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] }) return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
} }
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] }) if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/api/model/default")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: null,
})
if (url.pathname === "/session") return json([]) if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" }) if (url.pathname === "/vcs") return json({ branch: "main" })
if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" }) if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })