@@ -136,9 +131,6 @@ export function ServerConnectionList(props: {
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
key={(x) => x.http.url}
- onSelect={(x) => {
- if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
- }}
divider={true}
>
{(i) => {
@@ -163,15 +155,6 @@ export function ServerConnectionList(props: {
showCredentials
/>
-
-
-
-
{
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
- [...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
+ [...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
@@ -102,7 +102,7 @@ export const ProjectSettingsExtensions: Component = () => {
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
- const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
+ const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 6c0045fcac7..731a5eba4ff 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -45,7 +45,6 @@ import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
-import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 9559dd83bb2..ab905c28c67 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -218,14 +218,6 @@ beforeAll(async () => {
usePrompt: () => prompt,
}))
- mock.module("@/context/layout", () => ({
- useLayout: () => ({
- handoff: {
- setTabs: () => undefined,
- },
- }),
- }))
-
mock.module("@/context/sdk", () => ({
useSDK: () => {
return () => ({
@@ -262,7 +254,7 @@ beforeAll(async () => {
}))
mock.module("@/context/server-sync", () => ({
- useServerSync: () => () => {
+ useServerSync: () => {
const server = activeServerSync
return {
session: {
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 73c75b2abcb..da717534f21 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -8,7 +8,6 @@ import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
-import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
@@ -232,7 +231,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
- const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
@@ -250,7 +248,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
- serverSync().session.set("todo", sessionID, [])
+ serverSync.session.set("todo", sessionID, [])
input.onAbort?.()
@@ -330,7 +328,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const submissionSDK = sdk()
const submissionSync = sync()
- const submissionServerSync = serverSync()
+ const submissionServerSync = serverSync
const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory
const sessionID = params.id
@@ -340,10 +338,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
- const handoff = layout.handoff
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
- const permissionState = permission.currentServerState()
+ const permissionState = permission
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
@@ -418,7 +415,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
- handoff.setTabs(base64Encode(sessionDirectory), session.id)
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(
diff --git a/packages/app/src/components/provider-connection-controller.ts b/packages/app/src/components/provider-connection-controller.ts
index a9136fa3c8b..271ea032097 100644
--- a/packages/app/src/components/provider-connection-controller.ts
+++ b/packages/app/src/components/provider-connection-controller.ts
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
- serverSDK()
- .api.integration.get({ integrationID: input.provider, location: location() })
+ serverSDK.api.integration
+ .get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo(() => {
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
cancelPolling()
const directory = options.directory()
await queryClient
- .refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
+ .refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
- const result = await serverSDK()
- .api.integration.oauth.status({
+ const result = await serverSDK.api.integration.oauth
+ .status({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
return
}
dispatch({ type: "auth.pending" })
- const result = await serverSDK()
- .api.integration.oauth.connect({
+ const result = await serverSDK.api.integration.oauth
+ .connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
- await serverSDK().api.integration.connect.key({
+ await serverSDK.api.integration.connect.key({
integrationID: options.provider(),
location: location(),
key,
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
- const result = await serverSDK()
- .api.integration.oauth.complete({
+ const result = await serverSDK.api.integration.oauth
+ .complete({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
diff --git a/packages/app/src/components/server/server-management-controller.ts b/packages/app/src/components/server/server-management-controller.ts
index 09d7afde573..44acf356322 100644
--- a/packages/app/src/components/server/server-management-controller.ts
+++ b/packages/app/src/components/server/server-management-controller.ts
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
-import { ServerConnection, useServer } from "@/context/server"
+import { ServerConnection, useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health"
@@ -49,7 +49,7 @@ function useDefaultServer() {
}
export function useServerActionsController() {
- const server = useServer()
+ const server = useServers()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -72,28 +72,16 @@ export function useServerActionsController() {
export type ServerActionsController = ReturnType
export function useServerCollectionController() {
- const server = useServer()
+ const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
- const items = createMemo(() => {
- const current = server.current
- const list = server.list
- if (!current) return list
- if (!list.includes(current)) return [current, ...list]
- return [current, ...list.filter((item) => item !== current)]
- })
- const current = createMemo(() =>
- settings.general.newLayoutDesigns()
- ? undefined
- : (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
- )
+ const items = createMemo(() => server.list)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
- const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
@@ -101,8 +89,6 @@ export function useServerCollectionController() {
return 1
}
return list.slice().sort((a, b) => {
- if (a === active) return -1
- if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
@@ -113,7 +99,6 @@ export function useServerCollectionController() {
return {
collection: {
items: sorted,
- current,
health: () => global.servers.health,
},
...actions,
@@ -121,21 +106,3 @@ export function useServerCollectionController() {
}
export type ServerCollectionController = ReturnType
-
-export function useServerDomainController(options: { onSelect?: () => void } = {}) {
- const navigate = useNavigate()
- const server = useServer()
- const global = useGlobal()
- const collection = useServerCollectionController()
-
- const select = async (connection: ServerConnection.Any) => {
- if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
- options.onSelect?.()
- navigate("/")
- queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
- }
-
- return { ...collection, selection: { select } }
-}
-
-export type ServerDomainController = ReturnType
diff --git a/packages/app/src/components/server/server-management.test.ts b/packages/app/src/components/server/server-management.test.ts
index a26489756b3..e3d52f0f278 100644
--- a/packages/app/src/components/server/server-management.test.ts
+++ b/packages/app/src/components/server/server-management.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred() {
@@ -65,20 +65,17 @@ describe("replaceServerConnection", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
- active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
- setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
- "active:https://new.example.com",
"remove:https://old.example.com",
])
})
@@ -87,10 +84,8 @@ describe("replaceServerConnection", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
- active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
- setActive: () => {},
remove: (key) => removed.push(key),
})
diff --git a/packages/app/src/components/server/server-management.ts b/packages/app/src/components/server/server-management.ts
index 1d343efda13..5b228339c91 100644
--- a/packages/app/src/components/server/server-management.ts
+++ b/packages/app/src/components/server/server-management.ts
@@ -1,4 +1,4 @@
-import { normalizeServerUrl, ServerConnection } from "@/context/server"
+import { normalizeServerUrl, ServerConnection } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
@@ -42,18 +42,13 @@ export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
- active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
- setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
- const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
- const nextActive = active === originalKey ? ServerConnection.key(added) : active
- if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx
index 619c8a50be5..bf4eb2a7fc9 100644
--- a/packages/app/src/components/server/server-row-menu.tsx
+++ b/packages/app/src/components/server/server-row-menu.tsx
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx
index 0c98bc9c18c..8bcf4db51ff 100644
--- a/packages/app/src/components/server/server-row.tsx
+++ b/packages/app/src/components/server/server-row.tsx
@@ -11,7 +11,7 @@ import {
Show,
} from "solid-js"
import { useLanguage } from "@/context/language"
-import { type ServerConnection, serverName } from "@/context/server"
+import { type ServerConnection, serverName } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index c1e0ccde059..0fd47c694c8 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -39,8 +39,7 @@ function openSessionContext(args: {
}) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
- void args.tabs.open("context")
- args.tabs.setActive("context")
+ void args.tabs.open("context").then(() => args.tabs.setActive("context"))
}
export function SessionContextUsage(props: SessionContextUsageProps) {
diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx
index 93693746552..f133107e3b9 100644
--- a/packages/app/src/components/session-workspace-menu.tsx
+++ b/packages/app/src/components/session-workspace-menu.tsx
@@ -30,14 +30,14 @@ export function SessionWorkspaceMenu(props: {
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
- const blocked = () => props.eligible === false || serverSync().session.data.session_working(props.sessionID)
+ const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
- const sdk = serverSDK()
+ const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
@@ -50,7 +50,7 @@ export function SessionWorkspaceMenu(props: {
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
- const sdk = serverSDK()
+ const sdk = serverSDK
const sessionID = props.sessionID
setStore("selected", selection)
@@ -125,7 +125,7 @@ export function SessionWorkspaceMenu(props: {
)
}
-async function createWorkspace(project: Project, serverSDK: ReturnType>) {
+async function createWorkspace(project: Project, serverSDK: ReturnType) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx
index cd363d00c49..a8bc9b936c9 100644
--- a/packages/app/src/components/session/open-in-app.tsx
+++ b/packages/app/src/components/session/open-in-app.tsx
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
-import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
+import { useServer } from "@/context/server"
export const OPEN_APPS = [
"vscode",
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
app: undefined as OpenApp | undefined,
})
- const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+ const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index dc6cdaa6f3f..2719f4c9faa 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -1,32 +1,12 @@
-import { AppIcon } from "@opencode-ai/ui/app-icon"
-import { Button } from "@opencode-ai/ui/button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Keybind } from "@opencode-ai/ui/keybind"
-import { Spinner } from "@opencode-ai/ui/spinner"
-import { showToast } from "@/utils/toast"
-import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { getFilename } from "@opencode-ai/core/util/path"
-import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
-import { createStore } from "solid-js/store"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
-import { useLayout } from "@/context/layout"
-import { usePlatform } from "@/context/platform"
-import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
-import { useSync } from "@/context/sync"
-import { useTerminal } from "@/context/terminal"
-import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
-import { messageAgentColor } from "@/utils/agent"
-import { decode64 } from "@/utils/base64"
-import { fileManagerApp } from "@/utils/file-manager"
-import { Persist, persisted } from "@/utils/persist"
-import { StatusPopover, StatusPopoverV2 } from "../status-popover"
+import { StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -34,206 +14,15 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
-const OPEN_APPS = [
- "vscode",
- "cursor",
- "zed",
- "textmate",
- "antigravity",
- "finder",
- "terminal",
- "iterm2",
- "ghostty",
- "warp",
- "xcode",
- "android-studio",
- "powershell",
- "sublime-text",
-] as const
-
-type OpenApp = (typeof OPEN_APPS)[number]
-type OS = "macos" | "windows" | "linux" | "unknown"
-
-const MAC_APPS = [
- {
- id: "vscode",
- label: "session.header.open.app.vscode",
- icon: "vscode",
- openWith: "Visual Studio Code",
- },
- { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
- { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
- { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
- {
- id: "antigravity",
- label: "session.header.open.app.antigravity",
- icon: "antigravity",
- openWith: "Antigravity",
- },
- { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
- { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
- { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
- { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
- { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
- {
- id: "android-studio",
- label: "session.header.open.app.androidStudio",
- icon: "android-studio",
- openWith: "Android Studio",
- },
- {
- id: "sublime-text",
- label: "session.header.open.app.sublimeText",
- icon: "sublime-text",
- openWith: "Sublime Text",
- },
-] as const
-
-const WINDOWS_APPS = [
- { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
- { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
- { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
- {
- id: "powershell",
- label: "session.header.open.app.powershell",
- icon: "powershell",
- openWith: "powershell",
- },
- {
- id: "sublime-text",
- label: "session.header.open.app.sublimeText",
- icon: "sublime-text",
- openWith: "Sublime Text",
- },
-] as const
-
-const LINUX_APPS = [
- { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
- { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
- { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
- {
- id: "sublime-text",
- label: "session.header.open.app.sublimeText",
- icon: "sublime-text",
- openWith: "Sublime Text",
- },
-] as const
-
-const detectOS = (platform: ReturnType): OS => {
- if (platform.platform === "desktop" && platform.os) return platform.os
- if (typeof navigator !== "object") return "unknown"
- const value = navigator.platform || navigator.userAgent
- if (/Mac/i.test(value)) return "macos"
- if (/Win/i.test(value)) return "windows"
- if (/Linux/i.test(value)) return "linux"
- return "unknown"
-}
-
-const showRequestError = (language: ReturnType, err: unknown) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
-}
-
export function SessionHeader() {
- const layout = useLayout()
const command = useCommand()
- const server = useServer()
- const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
- const sync = useSync()
- const terminal = useTerminal()
- const { params, view } = useSessionLayout()
+ const { view } = useSessionLayout()
- const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
- const project = createMemo(() => {
- const directory = projectDirectory()
- if (!directory) return
- return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
- })
- const name = createMemo(() => {
- const current = project()
- if (current) return current.name || getFilename(current.worktree)
- return getFilename(projectDirectory())
- })
- const hotkey = createMemo(() => command.keybind("file.open"))
- const os = createMemo(() => detectOS(platform))
- const isV2 = settings.general.newLayoutDesigns
- const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
- const [exists, setExists] = createStore>>({
- finder: true,
- })
-
- const apps = createMemo(() => {
- if (os() === "macos") return MAC_APPS
- if (os() === "windows") return WINDOWS_APPS
- return LINUX_APPS
- })
-
- const fileManager = createMemo(() => fileManagerApp(os()))
-
- createEffect(() => {
- if (platform.platform !== "desktop") return
- if (!platform.checkAppExists) return
-
- const list = apps()
-
- setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>)
-
- void Promise.all(
- list.map((app) =>
- Promise.resolve(platform.checkAppExists?.(app.openWith))
- .then((value) => Boolean(value))
- .catch(() => false)
- .then((ok) => [app.id, ok] as const),
- ),
- ).then((entries) => {
- setExists(Object.fromEntries(entries) as Partial>)
- })
- })
-
- const options = createMemo(() => {
- return [
- { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
- ...apps()
- .filter((app) => exists[app.id])
- .map((app) => ({ ...app, label: language.t(app.label) })),
- ] as const
- })
-
- const toggleTerminal = () => {
- const next = !view().terminal.opened()
- view().terminal.toggle()
- if (!next) return
-
- const id = terminal.active()
- if (!id) return
- focusTerminalById(id)
- }
-
- const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
- const [menu, setMenu] = createStore({ open: false })
- const [openRequest, setOpenRequest] = createStore({
- app: undefined as OpenApp | undefined,
- })
-
- const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
- const current = createMemo(
- () =>
- options().find((o) => o.id === prefs.app) ??
- options()[0] ??
- ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
- )
- const opening = createMemo(() => openRequest.app !== undefined)
- const tint = createMemo(() =>
- messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
- )
const v2ActionsState = createMemo(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
@@ -244,275 +33,16 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
- const selectApp = (app: OpenApp) => {
- if (!options().some((item) => item.id === app)) return
- setPrefs("app", app)
- }
-
- const openDir = (app: OpenApp) => {
- if (opening() || !canOpen() || !platform.openPath) return
- const directory = projectDirectory()
- if (!directory) return
-
- const item = options().find((o) => o.id === app)
- const openWith = item && "openWith" in item ? item.openWith : undefined
- setOpenRequest("app", app)
- platform
- .openPath(directory, openWith)
- .catch((err: unknown) => showRequestError(language, err))
- .finally(() => {
- setOpenRequest("app", undefined)
- })
- }
-
- const copyPath = () => {
- const directory = projectDirectory()
- if (!directory) return
- navigator.clipboard
- .writeText(directory)
- .then(() => {
- showToast({
- variant: "success",
- icon: "circle-check",
- title: language.t("session.share.copy.copied"),
- description: directory,
- })
- })
- .catch((err: unknown) => showRequestError(language, err))
- }
-
- const [centerMount, setCenterMount] = createSignal(null)
const rightMount = useTitlebarRightMount()
- onMount(() => {
- setCenterMount(document.getElementById("opencode-titlebar-center"))
- })
return (
- <>
-
- {(mount) => (
-
-
-
- )}
-
-
- {(mount) => (
-
-
-
-
-
-
-
- }
- >
-
-
-
-
setMenu("open", open)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- >
-
diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx
index 738d282d72d..49b004fd1a7 100644
--- a/packages/app/src/components/settings-v2/models.tsx
+++ b/packages/app/src/components/settings-v2/models.tsx
@@ -25,7 +25,7 @@ export const SettingsModelsV2: Component = () => {
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
- Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
+ Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record
}),
)
diff --git a/packages/app/src/components/settings-v2/parts/server-select.tsx b/packages/app/src/components/settings-v2/parts/server-select.tsx
index 7d99dc26788..816fdbd45f0 100644
--- a/packages/app/src/components/settings-v2/parts/server-select.tsx
+++ b/packages/app/src/components/settings-v2/parts/server-select.tsx
@@ -1,7 +1,7 @@
import { Show, createMemo, type Component } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { useGlobal } from "@/context/global"
-import { ServerConnection, serverName } from "@/context/server"
+import { ServerConnection, serverName } from "@/context/servers"
const allServers = { type: "all" } as const
type ServerOption = ServerConnection.Any | typeof allServers
diff --git a/packages/app/src/components/settings-v2/projects.tsx b/packages/app/src/components/settings-v2/projects.tsx
index 04afe99959a..1359378710d 100644
--- a/packages/app/src/components/settings-v2/projects.tsx
+++ b/packages/app/src/components/settings-v2/projects.tsx
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useGlobal } from "@/context/global"
import { getProjectAvatarVariant } from "@/context/layout"
-import { ServerConnection, serverName } from "@/context/server"
+import { ServerConnection, serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
import { InlineServerSelect } from "./parts/server-select"
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx
index 0615000bcd8..6e02686ada4 100644
--- a/packages/app/src/components/settings-v2/providers.tsx
+++ b/packages/app/src/components/settings-v2/providers.tsx
@@ -97,7 +97,7 @@ export const SettingsProvidersV2: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
- const provider = serverSync().data.config.provider?.[providerID]
+ const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -106,13 +106,13 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined
- await serverSdk()
- .api.integration.get({ integrationID: providerID, location })
+ await serverSdk.api.integration
+ .get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
- credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
+ credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
)
showToast({
variant: "success",
diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx
index e30e9e99dbd..748c477f9ec 100644
--- a/packages/app/src/components/settings-v2/servers.tsx
+++ b/packages/app/src/components/settings-v2/servers.tsx
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
-import { ServerConnection, serverName } from "@/context/server"
+import { ServerConnection, serverName } from "@/context/servers"
import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx
index c0baff78a4a..3b316bbe066 100644
--- a/packages/app/src/components/settings-v2/workspaces.tsx
+++ b/packages/app/src/components/settings-v2/workspaces.tsx
@@ -22,7 +22,7 @@ import { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import type { Project } from "@/types"
import {
containsDirectory,
@@ -56,9 +56,9 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
transaction: undefined as "confirm" | "running" | undefined,
})
- const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
+ const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
const projects = createMemo(() =>
- serverSync().data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
+ serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
@@ -70,8 +70,8 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
- const sdk = serverSDK()
- return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
+ const sdk = serverSDK
+ return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
@@ -81,7 +81,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
)
}
const sessionQuery = useQuery(() => ({
- queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
+ queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index cfb1232c67e..427c86458d6 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -5,12 +5,13 @@ import { createEffect, createMemo, createResource, For, type JSXElement, onClean
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
-import { ServerConnection, useServer } from "@/context/server"
+import { ServerConnection, useServers } from "@/context/servers"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
+import { useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -103,9 +104,6 @@ type ServerStatusItem = {
export function StatusPopoverBody(props: { shown: boolean }) {
const sync = useSync()
const sdk = useSDK()
- const global = useGlobal()
- const server = useServer()
- const platform = usePlatform()
const language = useLanguage()
const fail = (err: unknown) => {
@@ -117,17 +115,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
}
let dialogRun = 0
- let dialogDead = false
onCleanup(() => {
- dialogDead = true
dialogRun += 1
})
- const sortedServers = createMemo(() => {
- const list = global.servers.list()
- return listServersByHealth(list, server.key, global.servers.health)
- })
const toggleMcp = useMcpToggle()
- const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 189be5ce2d5..9d4689515b4 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
-import { ServerConnection, useServer } from "@/context/server"
+import { ServerConnection, useServers } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
@@ -14,6 +14,7 @@ import {
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
+import { useServer } from "@/context/server"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -82,16 +83,12 @@ export function StatusPopover() {
}
export function StatusPopoverV2() {
- return
-}
-
-function DirectoryStatusPopover() {
const language = useLanguage()
- const server = useServerSDK()
+ const server = useServer()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
- const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
+ const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
diff --git a/packages/app/src/components/titlebar-session-events.test.ts b/packages/app/src/components/titlebar-session-events.test.ts
index d73abe944dc..18301a98360 100644
--- a/packages/app/src/components/titlebar-session-events.test.ts
+++ b/packages/app/src/components/titlebar-session-events.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key
diff --git a/packages/app/src/components/titlebar-session-events.ts b/packages/app/src/components/titlebar-session-events.ts
index 7d914d7c71b..2d5727e0ac6 100644
--- a/packages/app/src/components/titlebar-session-events.ts
+++ b/packages/app/src/components/titlebar-session-events.ts
@@ -1,9 +1,9 @@
-import type { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
- server?: ServerConnection.Key
+ server: ServerConnection.Key
directory: string
sessionIDs: string[]
}
@@ -19,16 +19,15 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
+ if (!("server" in detail) || typeof detail.server !== "string") return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
- if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
- server:
- "server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
+ server: ServerConnection.Key.make(detail.server),
directory: detail.directory,
sessionIDs,
}
diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx
index aec1b25c0f7..a64aec6687f 100644
--- a/packages/app/src/components/titlebar-tab-nav.tsx
+++ b/packages/app/src/components/titlebar-tab-nav.tsx
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
-import { useGlobal } from "@/context/global"
+import { useGlobal, useServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
-import { ServerConnection, serverName } from "@/context/server"
+import { ServerConnection, serverName, useServers } from "@/context/servers"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise"
@@ -46,11 +46,8 @@ export function TabNavItem(props: {
event.stopPropagation()
props.onClose()
}
- const global = useGlobal()
- const serverCtx = createMemo(() => {
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
- if (conn) return global.ensureServerCtx(conn)
- })
+ const servers = useServers()
+ const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const project = createMemo(() => {
const session = props.session
if (!session) return
@@ -74,8 +71,8 @@ export function TabNavItem(props: {
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
- if (global.servers.list().length <= 1) return
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
+ if (servers.list.length <= 1) return
+ const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx
index 9f395bb123b..845290a44f3 100644
--- a/packages/app/src/components/titlebar-tab-strip.tsx
+++ b/packages/app/src/components/titlebar-tab-strip.tsx
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
-import { useGlobal, type ServerCtx } from "@/context/global"
+import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
@@ -339,10 +339,9 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
- const serverCtx = createMemo(() => {
+ const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
- if (conn) return global.ensureServerCtx(conn)
+ return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
if (tab.type === "session") {
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index 7b0a1ae93b7..831e45dbf81 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
-import { useGlobal } from "@/context/global"
-import { ServerConnection, useServer } from "@/context/server"
+import { useGlobal, useServerCtx } from "@/context/global"
+import { ServerConnection, useServers } from "@/context/servers"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
@@ -61,15 +61,13 @@ export function useTitlebarRightMount() {
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
- const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
- const server = useServer()
+ const servers = useServers()
const navigate = useNavigate()
const location = useLocation()
- const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
@@ -77,11 +75,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
- const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
- const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
@@ -97,14 +93,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
const path = () => `${location.pathname}${location.search}${location.hash}`
- const creating = createMemo(() => {
- const route = layout.route()
- if (route.type === "draft" || route.type === "dir-new-sesssion") return true
- if (!params.dir) return false
- if (params.id) return false
- const parts = location.pathname.replace(/\/+$/, "").split("/")
- return parts.at(-1) === "session"
- })
createEffect(() => {
const current = path()
@@ -116,10 +104,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
})
- const canBack = createMemo(() => history.index > 0)
- const canForward = createMemo(() => history.index < history.stack.length - 1)
- const hasProjects = createMemo(() => layout.projects.list().length > 0)
- const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo(() => {
const installing = props.update?.installing ?? false
const version = props.update?.version
@@ -188,7 +172,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
data-tauri-drag-region
>
-
+
{(_) => {
const layout = useLayout()
const global = useGlobal()
@@ -200,9 +184,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
() => {
const route = layout.route()
if (route.type !== "session") return undefined
- const conn = global.servers
- .list()
- .find((item) => ServerConnection.key(item) === (route.server ?? server.key))
+ const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
@@ -245,7 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
- const next = { server: route.server ?? server.key, sessionId }
+ const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next)
}
})
@@ -258,53 +240,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const openNewTab = () => {
const route = layout.route()
- const activeSession = session()
- if (route.type === "session" && activeSession) {
- const sessionTab = {
- type: "session" as const,
- server: route.server ?? server.key,
- sessionId: activeSession.id,
- }
- const model = tabs.stateValue(sessionTab, "prompt")?.model.current()
- tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
- return
- }
+ switch (route.type) {
+ case "session": {
+ const activeSession = session()
+ if (!activeSession) return
- const activeTab = currentTab()
- if (activeTab?.type === "draft") {
- const model = tabs.stateValue(activeTab, "prompt")?.model.current()
- tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
- return
- }
-
- if (route.type === "home") {
- const selection = layout.home.selection()
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
- const project = conn
- ? global
- .ensureServerCtx(conn)
- .projects.list()
- .find((item) => item.worktree === selection.directory)
- : undefined
- if (conn && project) {
- tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
+ const sessionTab = {
+ type: "session" as const,
+ server: route.server,
+ sessionId: activeSession.id,
+ }
+ const model = tabs.stateValue(sessionTab, "prompt")?.model.current()
+ tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
}
+ case "draft": {
+ const activeTab = currentTab()
+ if (activeTab?.type !== "draft") return
+
+ const model = tabs.stateValue(activeTab, "prompt")?.model.current()
+ tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
+ return
+ }
+ case "home": {
+ const selection = layout.home.selection()
+ const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
+ const project = conn
+ ? global
+ .ensureServerCtx(conn)
+ .projects.list()
+ .find((item) => item.worktree === selection.directory)
+ : undefined
+ if (conn && project) {
+ tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
+ return
+ }
+ }
}
-
- const current = layout.projects.list()[0]
- if (current) {
- tabs.newDraft({ server: server.key, directory: current.worktree }, "")
- return
- }
-
- const fallback = global.servers.list().flatMap((conn) => {
- const project = global.ensureServerCtx(conn).projects.list()[0]
- return project ? [{ server: ServerConnection.key(conn), project }] : []
- })[0]
- if (!fallback) return
-
- tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
@@ -430,158 +402,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
)
}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
)
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index 09e890a5e51..02e66505704 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(
- serverSDK().scope,
+ serverSDK.scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 3378aec6511..20e3868d61e 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
- SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
+ SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
)
const inflight = new Map>()
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
- const viewCache = createFileViewCache(serverSDK().scope)
+ const viewCache = createFileViewCache(serverSDK.scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
- serverSDK()
- .api.file.find(
+ serverSDK.api.file
+ .find(
{
location: { directory: sdk().directory },
query,
diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx
index 88de24381b6..3c1962b0c56 100644
--- a/packages/app/src/context/global.tsx
+++ b/packages/app/src/context/global.tsx
@@ -1,18 +1,20 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
-import { createEffect, createMemo, createRoot } from "solid-js"
+import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
-import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
+import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import type { ServerScope } from "@/utils/server-scope"
+import { createServerPermissionState } from "./permission"
+import { createServerNotificationState } from "./notification"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
- const server = useServer()
+ const server = useServers()
const serverHealth = useServerHealth(
() => server.list,
() => true,
@@ -34,23 +36,21 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
- const serverCtxs = new Map<
- ServerConnection.Key,
- { dispose: () => void; serverCtx: ReturnType }
- >()
+ const serverCtxs = new Map>()
+ const serverCtxDisposers = new Map void>()
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
- if (existing) return existing.serverCtx
- const root = createRoot((dispose) => {
- const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
- return { dispose, serverCtx }
+ if (existing) return existing
+ const serverCtx = createRoot((dispose) => {
+ serverCtxDisposers.set(key, dispose)
+ return createServerController(conn, server.scope(key), server.projects.forServer(key))
}, owner as any)
- serverCtxs.set(key, root)
- return root.serverCtx
+ serverCtxs.set(key, serverCtx)
+ return serverCtx
}
createMemo(() => {
@@ -62,8 +62,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
- const { dispose } = serverCtxs.get(key)!
- dispose()
+ serverCtxDisposers.get(key)?.()
+ serverCtxDisposers.delete(key)
serverCtxs.delete(key)
}
}
@@ -92,13 +92,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
-function createServerCtx(
+function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType,
) {
+ const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
+ const permission = createServerPermissionState({ sdk, sync })
+ const notification = createServerNotificationState({ sdk, sync, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -139,10 +142,22 @@ function createServerCtx(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
+ permission,
+ notification,
}
}
-export type ServerCtx = ReturnType
+export function useServerCtx(server: Accessor): Accessor
+export function useServerCtx(server: Accessor): Accessor
+export function useServerCtx(server: Accessor) {
+ const global = useGlobal()
+ return () => {
+ const s = server()
+ if (s) return global.ensureServerCtx(s)
+ }
+}
+
+export type ServerCtx = ReturnType
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index fd35fe3b206..875ceacdd6b 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -4,13 +4,10 @@ import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
-import { useServerSDK } from "./server-sdk"
-import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
+import { ServerConnection, useServers } from "./servers"
import { usePlatform } from "./platform"
import type { Project } from "@/types"
-import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
-import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -19,7 +16,6 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
-import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -75,13 +71,6 @@ type SessionView = {
todoCollapsed?: boolean
}
-type TabHandoff = {
- scope: ServerScope
- dir: string
- id: string
- at: number
-}
-
export type LocalProject = Partial & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
@@ -91,9 +80,8 @@ export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
| { type: "home" }
- | { type: "draft"; draftID: string; server?: ServerConnection.Key }
- | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
- | { type: "session"; sessionId: string; server?: ServerConnection.Key }
+ | { type: "draft"; draftID: string }
+ | { type: "session"; sessionId: string; server: ServerConnection.Key }
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
@@ -148,34 +136,23 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
+ if (parts[1] === "session") return { type: "home" }
- if (parts[1] !== "session") return { type: "home" }
+ throw new Error("Unrecognised route!")
+}
- const id = parts[2]
- if (id) return { type: "session", sessionId: id }
- return { type: "dir-new-sesssion", dir, dirBase64 }
+export const useCurrentRoute = () => {
+ const location = useLocation()
+ return createMemo(() => currentRoute(location.pathname, location.search))
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
init: () => {
- const serverSdk = useServerSDK()
- const serverSync = useServerSync()
- const server = useServer()
- const tabs = useTabs()
+ // const serverSync = useServerSync()
+ const servers = useServers()
const platform = usePlatform()
- const location = useLocation()
- const route = createMemo(() => {
- const value = currentRoute(location.pathname, location.search)
- if (value.type === "home") return value
- if (value.server) return value
- if (value.type === "draft") {
- const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
- if (draft) return { ...value, server: draft.server }
- }
- return { ...value, server: server.key }
- })
const isRecord = (value: unknown): value is Record =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -267,9 +244,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
- const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
- { ...target, migrate },
+ { ...Persist.global("layout", ["layout.v6"]), migrate },
createStore({
sidebar: {
opened: false,
@@ -298,11 +274,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record,
sessionView: {} as Record,
- handoff: {
- tabs: undefined as TabHandoff | undefined,
- },
home: {
- selection: { server: server.key } as HomeProjectSelection,
+ selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
},
}),
)
@@ -442,168 +415,116 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return available[Math.floor(Math.random() * available.length)]
}
- function enrich(project: { worktree: string; expanded: boolean }) {
- const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
- const projectID = childStore.project
- const metadata = projectID
- ? serverSync().data.project.find((x) => x.id === projectID)
- : serverSync().data.project.find((x) => x.worktree === project.worktree)
+ // function enrich(project: { worktree: string; expanded: boolean }) {
+ // const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
+ // const projectID = childStore.project
+ // const metadata = projectID
+ // ? serverSync.data.project.find((x) => x.id === projectID)
+ // : serverSync.data.project.find((x) => x.worktree === project.worktree)
- // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
- // Without this, different subdirectories of the same git repo would share the same
- // icon from the database instead of using their individual overrides.
- const base = { ...metadata, ...project }
- if (childStore.icon) {
- return { ...base, icon: { ...base.icon, override: childStore.icon } }
- }
- return base
- }
+ // // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
+ // // Without this, different subdirectories of the same git repo would share the same
+ // // icon from the database instead of using their individual overrides.
+ // const base = { ...metadata, ...project }
+ // if (childStore.icon) {
+ // return { ...base, icon: { ...base.icon, override: childStore.icon } }
+ // }
+ // return base
+ // }
- const roots = createMemo(() => {
- const map = new Map()
- for (const project of serverSync().data.project) {
- const sandboxes = project.sandboxes ?? []
- for (const sandbox of sandboxes) {
- map.set(sandbox, project.worktree)
- }
- }
- return map
- })
+ // const roots = createMemo(() => {
+ // const map = new Map()
+ // for (const project of serverSync.data.project) {
+ // const sandboxes = project.sandboxes ?? []
+ // for (const sandbox of sandboxes) {
+ // map.set(sandbox, project.worktree)
+ // }
+ // }
+ // return map
+ // })
- const rootFor = (directory: string) => {
- const map = roots()
- if (map.size === 0) return directory
+ // const rootFor = (directory: string) => {
+ // const map = roots()
+ // if (map.size === 0) return directory
- const visited = new Set()
- const chain = [directory]
+ // const visited = new Set()
+ // const chain = [directory]
- while (chain.length) {
- const current = chain[chain.length - 1]
- if (!current) return directory
+ // while (chain.length) {
+ // const current = chain[chain.length - 1]
+ // if (!current) return directory
- const next = map.get(current)
- if (!next) return current
+ // const next = map.get(current)
+ // if (!next) return current
- if (visited.has(next)) return directory
- visited.add(next)
- chain.push(next)
- }
+ // if (visited.has(next)) return directory
+ // visited.add(next)
+ // chain.push(next)
+ // }
- return directory
- }
+ // return directory
+ // }
- createEffect(() => {
- const projects = server.projects.list()
- const seen = new Set(projects.map((project) => project.worktree))
+ const enriched = createMemo(() => [] /* servers.projects.list().map(enrich) */)
- batch(() => {
- for (const project of projects) {
- const root = rootFor(project.worktree)
- if (root === project.worktree) continue
+ // createEffect(() => {
+ // const projects = enriched()
+ // if (projects.length === 0) return
+ // if (!serverSync.ready) return
- server.projects.remove(project.worktree)
+ // for (const project of projects) {
+ // if (!project.id) continue
+ // if (project.id === "global") continue
+ // serverSync.project.icon(project.worktree, project.icon?.override)
+ // }
+ // })
- if (!seen.has(root)) {
- server.projects.open(root)
- seen.add(root)
- }
+ // createEffect(() => {
+ // const projects = enriched()
+ // if (projects.length === 0) return
- if (project.expanded) server.projects.expand(root)
- }
- })
- })
+ // for (const project of projects) {
+ // if (project.icon?.color) colorRequested.delete(project.worktree)
+ // }
- const enriched = createMemo(() => server.projects.list().map(enrich))
- const list = createMemo(() => {
- const projects = enriched()
- return projects.map((project) => {
- const color = project.icon?.color ?? colors[project.worktree]
- if (!color) return project
- const icon = project.icon ? { ...project.icon, color } : { color }
- return { ...project, icon }
- })
- })
+ // const used = new Set()
+ // for (const project of projects) {
+ // const color = project.icon?.color ?? colors[project.worktree]
+ // if (color) used.add(color)
+ // }
- createEffect(() => {
- const projects = enriched()
- if (projects.length === 0) return
- if (!serverSync().ready) return
+ // for (const project of projects) {
+ // if (project.icon?.color || project.icon?.override || project.icon?.url) continue
+ // const worktree = project.worktree
+ // const existing = colors[worktree]
+ // const color = existing ?? pickAvailableColor(used)
+ // if (!existing) {
+ // used.add(color)
+ // setColors(worktree, color)
+ // }
+ // if (!project.id) continue
- for (const project of projects) {
- if (!project.id) continue
- if (project.id === "global") continue
- serverSync().project.icon(project.worktree, project.icon?.override)
- }
- })
+ // const requested = colorRequested.get(worktree)
+ // if (requested === color) continue
+ // colorRequested.set(worktree, color)
- createEffect(() => {
- const projects = enriched()
- if (projects.length === 0) return
+ // if (project.id === "global") {
+ // serverSync.project.meta(worktree, { icon: { color } })
+ // continue
+ // }
- for (const project of projects) {
- if (project.icon?.color) colorRequested.delete(project.worktree)
- }
-
- const used = new Set()
- for (const project of projects) {
- const color = project.icon?.color ?? colors[project.worktree]
- if (color) used.add(color)
- }
-
- for (const project of projects) {
- if (project.icon?.color || project.icon?.override || project.icon?.url) continue
- const worktree = project.worktree
- const existing = colors[worktree]
- const color = existing ?? pickAvailableColor(used)
- if (!existing) {
- used.add(color)
- setColors(worktree, color)
- }
- if (!project.id) continue
-
- const requested = colorRequested.get(worktree)
- if (requested === color) continue
- colorRequested.set(worktree, color)
-
- if (project.id === "global") {
- serverSync().project.meta(worktree, { icon: { color } })
- continue
- }
-
- const projectID = project.id
- void (async () => {
- // TODO: Restore project color updates when the V2 client exposes a project update API.
- void projectID
- })().catch(() => {
- if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
- })
- }
- })
-
- let sessionFrame: number | undefined
- let sessionTimer: number | undefined
-
- onMount(() => {
- sessionFrame = requestAnimationFrame(() => {
- sessionFrame = undefined
- sessionTimer = window.setTimeout(() => {
- sessionTimer = undefined
- void Promise.all(
- server.projects.list().map((project) => {
- return serverSync().project.loadSessions(project.worktree)
- }),
- )
- }, 0)
- })
- })
-
- onCleanup(() => {
- if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
- if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
- })
+ // const projectID = project.id
+ // void (async () => {
+ // // TODO: Restore project color updates when the V2 client exposes a project update API.
+ // void projectID
+ // })().catch(() => {
+ // if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
+ // })
+ // }
+ // })
return {
- route,
+ route: useCurrentRoute(),
ready,
home: {
selection: createMemo(() => store.home.selection),
@@ -611,71 +532,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("home", "selection", reconcile(selection))
},
},
- handoff: {
- tabs: createMemo(() => store.handoff?.tabs),
- setTabs(dir: string, id: string) {
- setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
- },
- clearTabs() {
- if (!store.handoff?.tabs) return
- setStore("handoff", "tabs", undefined)
- },
- },
- projects: {
- list,
- recentlyClosed: createMemo(() => {
- const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
- return server.projects
- .recentlyClosed()
- .filter((worktree) => known.has(pathKey(worktree)))
- .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
- .map((worktree) => enrich({ worktree, expanded: false }))
- }),
- open(directory: string) {
- const root = rootFor(directory)
- if (server.projects.list().find((x) => x.worktree === root)) return
- void serverSync().project.loadSessions(root)
- server.projects.open(root)
- },
- close(directory: string) {
- server.projects.close(directory)
- },
- expand(directory: string) {
- server.projects.expand(directory)
- },
- collapse(directory: string) {
- server.projects.collapse(directory)
- },
- move(directory: string, toIndex: number) {
- server.projects.move(directory, toIndex)
- },
- },
- sidebar: {
- opened: createMemo(() => store.sidebar.opened),
- open() {
- setStore("sidebar", "opened", true)
- },
- close() {
- setStore("sidebar", "opened", false)
- },
- toggle() {
- setStore("sidebar", "opened", (x) => !x)
- },
- width: createMemo(() => store.sidebar.width),
- resize(width: number) {
- setStore("sidebar", "width", width)
- },
- workspaces(directory: string) {
- return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
- },
- setWorkspaces(directory: string, value: boolean) {
- setStore("sidebar", "workspaces", directory, value)
- },
- toggleWorkspaces(directory: string) {
- const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
- setStore("sidebar", "workspaces", directory, !current)
- },
- },
terminal: {
height: createMemo(() => store.terminal.height),
resize(height: number) {
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 83db571385f..c63c11aa9c5 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved, , savedReady] = persisted(
{
- ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
+ ...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore({
@@ -127,14 +127,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
- return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
+ return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
- const key = handoffKey(serverSDK().scope, sdk().directory, session)
+ const key = handoffKey(serverSDK.scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@@ -385,7 +385,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
- const key = handoffKey(serverSDK().scope, dir, session)
+ const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) {
@@ -400,7 +400,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
- if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
+ if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
setSaved("session", session, {
agent: msg.agent,
diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx
index 4eb18f38bf6..d480268cbdc 100644
--- a/packages/app/src/context/models.tsx
+++ b/packages/app/src/context/models.tsx
@@ -1,4 +1,4 @@
-import { createMemo, createResource } from "solid-js"
+import { type Accessor, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -22,152 +22,162 @@ function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
}
+const createModelsPersistedState = () => {
+ const [store, setStore, _, ready] = persisted(
+ Persist.global("model", ["model.v1"]),
+ createStore({
+ user: [],
+ recent: [],
+ variant: {},
+ }),
+ )
+
+ return [store, setStore, ready] as const
+}
+
+const createModelsController = (directory: Accessor) => {
+ const providers = useProviders(() => directory())
+
+ const [store, setStore, ready] = createModelsPersistedState()
+
+ const available = createMemo(() =>
+ providers.connected().flatMap((p) =>
+ Object.values(p.models).map((m) => ({
+ ...m,
+ provider: p,
+ })),
+ ),
+ )
+
+ const release = createMemo(
+ () =>
+ new Map(
+ available().map((model) => {
+ const parsed = DateTime.fromISO(model.release_date)
+ return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
+ }),
+ ),
+ )
+
+ const latest = createMemo(() =>
+ pipe(
+ available(),
+ filter(
+ (x) =>
+ Math.abs(
+ (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
+ .diffNow()
+ .as("months"),
+ ) < 6,
+ ),
+ groupBy((x) => x.provider.id),
+ mapValues((models) =>
+ pipe(
+ models,
+ groupBy((x) => x.family),
+ values(),
+ (groups) =>
+ groups.flatMap((g) => {
+ const first = firstBy(g, [(x) => x.release_date, "desc"])
+ return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
+ }),
+ ),
+ ),
+ values(),
+ flat(),
+ ),
+ )
+
+ const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
+
+ const visibility = createMemo(() => {
+ const map = new Map()
+ for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
+ return map
+ })
+
+ const list = createMemo(() =>
+ available().map((m) => ({
+ ...m,
+ name: m.name.replace("(latest)", "").trim(),
+ latest: m.name.includes("(latest)"),
+ })),
+ )
+
+ const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
+
+ function update(model: ModelKey, state: Visibility) {
+ const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
+ if (index >= 0) {
+ setStore("user", index, (current) => ({ ...current, visibility: state }))
+ return
+ }
+ setStore("user", store.user.length, { ...model, visibility: state })
+ }
+
+ const visible = (model: ModelKey) => {
+ const key = modelKey(model)
+ const state = visibility().get(key)
+ if (state === "hide") return false
+ if (state === "show") return true
+ if (latestSet().has(key)) return true
+ const date = release().get(key)
+ if (!date?.isValid) return true
+ return false
+ }
+
+ const setVisibility = (model: ModelKey, state: boolean) => {
+ update(model, state ? "show" : "hide")
+ }
+
+ const push = (model: ModelKey) => {
+ const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
+ if (uniq.length > RECENT_LIMIT) uniq.pop()
+ setStore("recent", uniq)
+ }
+
+ const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
+ const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
+
+ const setVariant = (model: ModelKey, value: string | undefined) => {
+ const key = variantKey(model)
+ if (!store.variant) {
+ setStore("variant", { [key]: value })
+ return
+ }
+ setStore("variant", key, value)
+ }
+
+ const [recentModels] = createResource(
+ async () => {
+ const recent = store.recent
+ await ready.promise
+ return recent
+ },
+ (p) => p,
+ { initialValue: [] },
+ )
+ return {
+ ready,
+ list,
+ find,
+ visible,
+ setVisibility,
+ recent: {
+ list: () => recentModels()!,
+ push,
+ },
+ variant: {
+ get: getVariant,
+ set: setVariant,
+ },
+ }
+}
+
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
- init: (props: { directory?: string } = {}) => {
- const providers = useProviders(() => props.directory)
-
- const [store, setStore, _, ready] = persisted(
- Persist.global("model", ["model.v1"]),
- createStore({
- user: [],
- recent: [],
- variant: {},
- }),
- )
-
- const available = createMemo(() =>
- providers.connected().flatMap((p) =>
- Object.values(p.models).map((m) => ({
- ...m,
- provider: p,
- })),
- ),
- )
-
- const release = createMemo(
- () =>
- new Map(
- available().map((model) => {
- const parsed = DateTime.fromISO(model.release_date)
- return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
- }),
- ),
- )
-
- const latest = createMemo(() =>
- pipe(
- available(),
- filter(
- (x) =>
- Math.abs(
- (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
- .diffNow()
- .as("months"),
- ) < 6,
- ),
- groupBy((x) => x.provider.id),
- mapValues((models) =>
- pipe(
- models,
- groupBy((x) => x.family),
- values(),
- (groups) =>
- groups.flatMap((g) => {
- const first = firstBy(g, [(x) => x.release_date, "desc"])
- return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
- }),
- ),
- ),
- values(),
- flat(),
- ),
- )
-
- const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
-
- const visibility = createMemo(() => {
- const map = new Map()
- for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
- return map
- })
-
- const list = createMemo(() =>
- available().map((m) => ({
- ...m,
- name: m.name.replace("(latest)", "").trim(),
- latest: m.name.includes("(latest)"),
- })),
- )
-
- const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
-
- function update(model: ModelKey, state: Visibility) {
- const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
- if (index >= 0) {
- setStore("user", index, (current) => ({ ...current, visibility: state }))
- return
- }
- setStore("user", store.user.length, { ...model, visibility: state })
- }
-
- const visible = (model: ModelKey) => {
- const key = modelKey(model)
- const state = visibility().get(key)
- if (state === "hide") return false
- if (state === "show") return true
- if (latestSet().has(key)) return true
- const date = release().get(key)
- if (!date?.isValid) return true
- return false
- }
-
- const setVisibility = (model: ModelKey, state: boolean) => {
- update(model, state ? "show" : "hide")
- }
-
- const push = (model: ModelKey) => {
- const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
- if (uniq.length > RECENT_LIMIT) uniq.pop()
- setStore("recent", uniq)
- }
-
- const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
- const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
-
- const setVariant = (model: ModelKey, value: string | undefined) => {
- const key = variantKey(model)
- if (!store.variant) {
- setStore("variant", { [key]: value })
- return
- }
- setStore("variant", key, value)
- }
-
- const [recentModels] = createResource(
- async () => {
- const recent = store.recent
- await ready.promise
- return recent
- },
- (p) => p,
- { initialValue: [] },
- )
- return {
- ready,
- list,
- find,
- visible,
- setVisibility,
- recent: {
- list: () => recentModels()!,
- push,
- },
- variant: {
- get: getVariant,
- set: setVariant,
- },
- }
+ init: (props: { directory?: string | Accessor } = {}) => {
+ return createModelsController(() => (typeof props.directory === "function" ? props.directory() : props.directory))
},
})
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index 7dbf014f781..52fb6fc3292 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -1,6 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
-import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync"
@@ -13,10 +12,11 @@ import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
-import { ServerConnection, useServer } from "./server"
+import { ServerConnection, useServers } from "./servers"
import { type DraftTab, useTabs } from "./tabs"
-import { requireServerKey } from "@/utils/session-route"
+import { requireServerKey, sessionHref } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
+import { useServer } from "./server"
type NotificationBase = {
directory?: string
@@ -109,131 +109,14 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
-export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
- name: "Notification",
- gate: false,
- init: () => {
- const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
- const [search] = useSearchParams<{ draftId?: string }>()
- const global = useGlobal()
- const server = useServer()
- const tabs = useTabs()
- const navigate = useNavigate()
- const platform = usePlatform()
- const settings = useSettings()
- const language = useLanguage()
- const owner = getOwner()
- const states = new Map void; state: NotificationState }>()
-
- const activeServer = createMemo(() => {
- if (params.serverKey) return requireServerKey(params.serverKey)
- if (search.draftId) {
- const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
- if (draft) return draft.server
- }
- return server.key
- })
- const activeDirectory = createMemo(() => decode64(params.dir))
- const activeSession = createMemo(() => params.id)
-
- const ensure = (key: ServerConnection.Key) => {
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
- if (!conn) throw new Error(`Notification server not found: ${key}`)
- const ctx = global.ensureServerCtx(conn)
- const existing = states.get(ctx.sdk.scope)
- if (existing) return existing.state
- const root = createRoot(
- (dispose) => ({
- dispose,
- state: createServerNotificationState({
- sdk: ctx.sdk,
- sync: ctx.sync,
- active: () => server.scope(activeServer()) === ctx.sdk.scope,
- directory: activeDirectory,
- sessionID: activeSession,
- platform,
- settings,
- language,
- navigate,
- }),
- }),
- owner ?? undefined,
- )
- states.set(ctx.sdk.scope, root)
- return root.state
- }
-
- createEffect(() => {
- global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
- })
-
- createEffect(() => {
- const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
- states.forEach((value, scope) => {
- if (scopes.has(scope)) return
- value.dispose()
- states.delete(scope)
- })
- })
-
- onCleanup(() => states.forEach((value) => value.dispose()))
-
- const selected = () => {
- const list = global.servers.list()
- const key = activeServer()
- if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
- const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
- if (!conn) throw new Error("Notification server not found")
- return ensure(ServerConnection.key(conn))
- }
-
- return {
- ready: () => selected().ready(),
- ensureServerState: ensure,
- session: {
- all: (session: string) => selected().session.all(session),
- unseen: (session: string) => selected().session.unseen(session),
- unseenCount: (session: string) => selected().session.unseenCount(session),
- unseenHasError: (session: string) => selected().session.unseenHasError(session),
- markViewed: (session: string) => selected().session.markViewed(session),
- },
- project: {
- all: (directory: string) => selected().project.all(directory),
- unseen: (directory: string) => selected().project.unseen(directory),
- unseenCount: (directory: string) => selected().project.unseenCount(directory),
- unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
- markViewed: (directory: string) => selected().project.markViewed(directory),
- },
- }
- },
-})
-
-type NotificationState = ReturnType
-
-function createServerNotificationState(input: {
- sdk: ServerSDK
- sync: ServerSync
- active: Accessor
- directory: Accessor
- sessionID: Accessor
- platform: ReturnType
- settings: ReturnType
- language: ReturnType
- navigate: (href: string) => void
-}) {
- const serverSDK = () => input.sdk
- const serverSync = () => input.sync
- const platform = input.platform
- const settings = input.settings
- const language = input.language
-
+export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
+ const platform = usePlatform()
+ const settings = useSettings()
+ const language = useLanguage()
const empty: Notification[] = []
- const currentDirectory = input.directory
- const currentSession = input.sessionID
-
const [store, setStore, _, ready] = persisted(
- Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
+ Persist.serverGlobal(input.sdk.scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
@@ -316,7 +199,7 @@ function createServerNotificationState(input: {
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
- const sync = serverSync().ensureDirSyncContext(directory)
+ const sync = input.sync.ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
@@ -325,17 +208,16 @@ function createServerNotificationState(input: {
.catch(() => undefined)
}
- const viewedInCurrentSession = (directory: string, sessionID?: string) => {
- if (!input.active()) return false
- const activeDirectory = currentDirectory()
- const activeSession = currentSession()
- if (!activeSession) return false
- if (!sessionID) return false
- if (activeDirectory && directory !== activeDirectory) return false
- return sessionID === activeSession
+ const viewedInCurrentSession = (sessionID: string) => {
+ return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
}
- const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
+ const navigate = (href: string) => {
+ history.pushState(null, "", href)
+ dispatchEvent(new PopStateEvent("popstate"))
+ }
+
+ const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
@@ -349,15 +231,15 @@ function createServerNotificationState(input: {
append({
directory,
time,
- viewed: viewedInCurrentSession(directory, sessionID),
+ viewed: viewedInCurrentSession(sessionID),
type: "turn-complete",
session: sessionID,
})
- const href = `/${base64Encode(directory)}/session/${sessionID}`
+ const href = sessionHref(input.key, sessionID)
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
- input.navigate(href),
+ navigate(href),
)
}
})
@@ -381,7 +263,7 @@ function createServerNotificationState(input: {
append({
directory,
time,
- viewed: viewedInCurrentSession(directory, sessionID),
+ viewed: viewedInCurrentSession(sessionID),
type: "error",
session: sessionID ?? "global",
error,
@@ -389,14 +271,14 @@ function createServerNotificationState(input: {
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
- const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
+ const href = sessionHref(input.key, sessionID ?? "global")
if (settings.notifications.errors()) {
- void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
+ void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
}
})
}
- const unsub = serverSDK().event.listen((e) => {
+ const unsub = input.sdk.event.listen((e) => {
const event = e.details
if (
event.type !== "session.execution.succeeded" &&
@@ -486,3 +368,8 @@ function createServerNotificationState(input: {
},
}
}
+
+export const useNotification = () => {
+ const server = useServer()
+ return server.ctx.notification
+}
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index 1cec5aa0de3..a4f9f840b13 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -8,7 +8,7 @@ import type { ServerSync } from "./server-sync"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global"
-import { ServerConnection, useServer } from "./server"
+import { ServerConnection, useServers } from "./servers"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
@@ -20,6 +20,7 @@ import {
autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond"
+import { useServer } from "./server"
type PermissionRespondFn = (input: {
sessionID: string
@@ -51,142 +52,9 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
-export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
- name: "Permission",
- gate: false,
- init: () => {
- const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
- const [search] = useSearchParams<{ draftId?: string }>()
- const global = useGlobal()
- const server = useServer()
- const tabs = useTabs()
- const settings = useSettings()
- const owner = getOwner()
- const states = new Map void; state: PermissionState }>()
-
- const activeDraft = createMemo(() => {
- if (!search.draftId) return
- return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
- })
-
- const activeServer = createMemo(() => {
- if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
- return activeDraft()?.server ?? server.key
- })
-
- const ensure = (key: ServerConnection.Key) => {
- const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
- if (!conn) throw new Error(`Permission server not found: ${key}`)
- const ctx = global.ensureServerCtx(conn)
- const existing = states.get(ctx.sdk.scope)
- if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
- return existing.state
- }
- if (existing) {
- existing.dispose()
- states.delete(ctx.sdk.scope)
- }
- const root = createRoot(
- (dispose) => ({
- key,
- dispose,
- state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
- }),
- owner ?? undefined,
- )
- states.set(ctx.sdk.scope, root)
- return root.state
- }
-
- createEffect(() => {
- global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
- })
-
- createEffect(() => {
- const list = global.servers.list()
- const keys = new Set(list.map(ServerConnection.key))
- states.forEach((value, scope) => {
- if (keys.has(value.key)) return
- value.dispose()
- states.delete(scope)
- const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
- if (replacement) ensure(ServerConnection.key(replacement))
- })
- })
-
- onCleanup(() => states.forEach((value) => value.dispose()))
-
- let lastSelected: PermissionState | undefined
- const selected = () => {
- const key = activeServer()
- if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
- lastSelected = ensure(key)
- }
- if (lastSelected) return lastSelected
- return ensure(server.key)
- }
- const activeDirectory = createMemo(() => {
- const directory = decode64(params.dir)
- if (directory) return directory
- const draft = activeDraft()
- if (draft) return draft.directory
- if (!params.id) return
- if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
- return selected().sync.session.lineage.peek(params.id)?.session.location.directory
- })
-
- createEffect(() => {
- const directory = activeDirectory()
- if (!directory) return
- selected().enableConfiguredDirectory(directory)
- })
-
- const permissionsEnabled = createMemo(() => {
- const directory = activeDirectory()
- if (!directory) return false
- return selected().permissionsEnabled(directory)
- })
-
- return {
- ready: () => selected().ready(),
- ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
- currentServerState: () => selected().api,
- respond(input: Parameters[0]) {
- selected().respond(input)
- },
- autoResponds(permission: PermissionRequest, directory?: string) {
- return selected().autoResponds(permission, directory)
- },
- isAutoAccepting(sessionID: string, directory?: string) {
- return selected().isAutoAccepting(sessionID, directory)
- },
- isAutoAcceptingDirectory(directory: string) {
- return selected().isAutoAcceptingDirectory(directory)
- },
- toggleAutoAccept(sessionID: string, directory: string) {
- selected().toggleAutoAccept(sessionID, directory)
- },
- toggleAutoAcceptDirectory(directory: string) {
- selected().toggleAutoAcceptDirectory(directory)
- },
- enableAutoAccept(sessionID: string, directory: string) {
- selected().enableAutoAccept(sessionID, directory)
- },
- disableAutoAccept(sessionID: string, directory?: string) {
- selected().disableAutoAccept(sessionID, directory)
- },
- permissionsEnabled,
- isPermissionAllowAll(directory: string) {
- return selected().isPermissionAllowAll(directory)
- },
- }
- },
-})
-
-type PermissionState = ReturnType
type PermissionEvent = Parameters[0]>[0]
-function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
+export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
@@ -474,3 +342,8 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
},
}
}
+
+export const usePermission = () => {
+ const server = useServer()
+ return server.ctx.permission
+}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 0408b233ffa..17b564b4567 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
-import { ServerConnection } from "./server"
+import { ServerConnection } from "./servers"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { DraftStore } from "@/utils/draft-store"
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 72b6628085f..7d4d9b7f0d9 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
-import { ServerConnection } from "./server"
+import { ServerConnection } from "./servers"
import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
@@ -102,14 +102,14 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
- params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
+ params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK.server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = settings.general.newLayoutDesigns()
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
: undefined
- if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
+ if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK.scope, scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const existing = cache.get(key)
@@ -121,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
- value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
+ value: createPromptSession(target?.scope ?? serverSDK.scope, scope),
dispose,
}),
owner,
diff --git a/packages/app/src/context/route.ts b/packages/app/src/context/route.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx
index 880691ce5f2..cbb0636229e 100644
--- a/packages/app/src/context/sdk.tsx
+++ b/packages/app/src/context/sdk.tsx
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
const context = createSimpleContext({
name: "SDK",
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
- init: (props: { directory: string }) => {
+ init: (props: { directory: string | Accessor }) => {
const serverSDK = useServerSDK()
- return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
+ return createMemo(() => {
+ const directory = typeof props.directory === "function" ? props.directory() : props.directory
+ return serverSDK.ensureDirSdkContext(directory)
+ })
},
})
diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx
index ff33d39b555..63942adaa99 100644
--- a/packages/app/src/context/server-sdk.tsx
+++ b/packages/app/src/context/server-sdk.tsx
@@ -1,17 +1,15 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
-import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
-import { type Accessor, batch, createMemo, onCleanup, onMount } 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 { useLanguage } from "./language"
import { usePlatform } from "./platform"
-import { ServerConnection, useServer } from "./server"
+import { ServerConnection } from "./servers"
import { createRefCountMap } from "@/utils/refcount"
-import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
+import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
@@ -347,22 +345,10 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
})
}
-export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
- name: "ServerSDK",
- // Returns an accessor so the resolved server can change reactively (e.g. a
- // /new-session draft retargeting its server) without re-instantiating the subtree.
- init: (props: { server?: ServerConnection.Any }) => {
- const global = useGlobal()
- const language = useLanguage()
- const server = useServer()
-
- return createMemo(() => {
- const conn = props.server ?? server.current
- if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
- return global.ensureServerCtx(conn).sdk
- })
- },
-})
+export const useServerSDK = () => {
+ const server = useServer()
+ return server.ctx.sdk
+}
type SDKEventMap = {
[key in Event["type"]]: Extract
diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx
index ce7bc0389c5..16c085cf5f2 100644
--- a/packages/app/src/context/server-sync.tsx
+++ b/packages/app/src/context/server-sync.tsx
@@ -1,8 +1,8 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
-import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
-import { createStore, produce, reconcile } from "solid-js/store"
+import { batch, getOwner, onCleanup, untrack } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { type ServerEvent, type ServerSDK } from "./server-sdk"
@@ -32,12 +32,9 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { pathKey, PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
-import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount"
-import { useGlobal } from "./global"
-import { ServerConnection, useServer } from "./server"
-import { retry } from "@opencode-ai/core/util/retry"
+import { ServerConnection } from "./servers"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
@@ -57,6 +54,7 @@ import { createServerSession, type ServerSession } from "./server-session"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
+import { useServer } from "./server"
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type = event.current?.type ?? event.type
@@ -812,24 +810,12 @@ export function createServerSyncContext(serverSDK: ServerSDK) {
export type ServerSync = ReturnType
-export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
- name: "ServerSync",
- // Returns an accessor so the resolved server can change reactively without
- // re-instantiating the subtree (mirrors useServerSDK).
- init: (props: { server?: ServerConnection.Any }) => {
- const global = useGlobal()
- const language = useLanguage()
- const server = useServer()
-
- return createMemo(() => {
- const conn = props.server ?? server.current
- if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
- return global.ensureServerCtx(conn).sync
- })
- },
-})
+export const useServerSync = () => {
+ const server = useServer()
+ return server.ctx.sync
+}
export function useQueryOptions() {
const sync = useServerSync()
- return createMemo(() => sync().queryOptions)
+ return sync.queryOptions
}
diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts
index d78880ecf10..ddd33ff2c9a 100644
--- a/packages/app/src/context/server.test.ts
+++ b/packages/app/src/context/server.test.ts
@@ -1,13 +1,11 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
-import { createStore } from "solid-js/store"
import {
- createServerProjects,
migrateCanonicalLocalServerState,
nextServerAfterRemoval,
resolveServerList,
ServerConnection,
-} from "./server"
+} from "./servers"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => {
@@ -95,110 +93,6 @@ test("active server removal falls back across built-in and persisted servers", (
).toBe(ServerConnection.Key.make("sidecar"))
})
-describe("createServerProjects", () => {
- test("keeps active and explicit server buckets in one reactive store", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const active = createServerProjects({ scope, store, setStore })
- const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
-
- remote.open("/repo")
- expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
- expect(active.list()).toEqual([])
-
- const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
- expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
-
- adopted.close("/repo")
- expect(remote.list()).toEqual([])
- dispose()
- })
- })
-
- test("tracks recently closed projects and drops them when reopened", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const projects = createServerProjects({ scope, store, setStore })
-
- projects.open("/a")
- projects.open("/b")
- projects.close("/a")
- expect(projects.recentlyClosed()).toEqual(["/a"])
-
- projects.close("/b")
- expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
-
- projects.open("/a")
- expect(projects.recentlyClosed()).toEqual(["/b"])
- expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
- dispose()
- })
- })
-
- test("remove drops a project without recording it as recently closed", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const projects = createServerProjects({ scope, store, setStore })
-
- projects.open("/repo/subdir")
- projects.remove("/repo/subdir")
- expect(projects.list()).toEqual([])
- expect(projects.recentlyClosed()).toEqual([])
- dispose()
- })
- })
-
- test("retains recently closed history beyond the visible display limit", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const projects = createServerProjects({ scope, store, setStore })
-
- // Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
- // this prevents display-filtered entries from evicting still-visible ones.
- for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
- projects.open(dir)
- projects.close(dir)
- }
- expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
- dispose()
- })
- })
-
- test("caps recently closed history at the store limit", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const projects = createServerProjects({ scope, store, setStore })
-
- for (let i = 1; i <= 20; i++) {
- projects.open(`/p${i}`)
- projects.close(`/p${i}`)
- }
- expect(projects.recentlyClosed()).toHaveLength(16)
- expect(projects.recentlyClosed()[0]).toBe("/p20")
- expect(projects.recentlyClosed().at(-1)).toBe("/p5")
- dispose()
- })
- })
-
- test("dedupes recently closed entries by normalized path", () => {
- createRoot((dispose) => {
- const [scope] = createSignal(ServerScope.local)
- const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
- const projects = createServerProjects({ scope, store, setStore })
-
- projects.close("/repo")
- projects.close("/repo/")
- expect(projects.recentlyClosed()).toEqual(["/repo/"])
- dispose()
- })
- })
-})
-
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 818bdf69b50..698709a5ccf 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -1,375 +1,23 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
-import { type Accessor, batch, createMemo } from "solid-js"
-import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
-import { Persist, persisted } from "@/utils/persist"
-import { pathKey } from "@/utils/path-key"
-import { ServerScope } from "@/utils/server-scope"
-
-type StoredProject = { worktree: string; expanded: boolean }
-type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
-type ServerProjectState = {
- projects: Record
- lastProject: Record
- recentlyClosed: Record
-}
-const HEALTH_POLL_INTERVAL_MS = 10_000
-// The store retains more history than is displayed. Consumers filter recently closed entries
-// against the live project list (dropping deleted projects) and then cap the visible count via
-// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
-// filtered out do not evict still-visible ones from the persisted store.
-const RECENTLY_CLOSED_HISTORY_LIMIT = 16
-export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
-
-export function normalizeServerUrl(input: string) {
- const trimmed = input.trim()
- if (!trimmed) return
- const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
- return withProtocol.replace(/\/+$/, "")
-}
-
-export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
- if (!conn) return ""
- if (conn.displayName && !ignoreDisplayName) return conn.displayName
- return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
-}
-
-function isLocalHost(url: string) {
- const host = url.replace(/^https?:\/\//, "").split(":")[0]
- if (host === "localhost" || host === "127.0.0.1") return "local"
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value)
-}
-
-export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
- if (!canonicalLocalServer || canonicalLocalServer === "local") return value
- if (!isRecord(value)) return value
- const projects = isRecord(value.projects) ? value.projects : undefined
- const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
- const previousProjects = projects?.[canonicalLocalServer]
- const previousLastProject = lastProject?.[canonicalLocalServer]
- if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
-
- const next = { ...value }
- if (projects && Array.isArray(previousProjects)) {
- const local = Array.isArray(projects.local) ? projects.local : []
- const worktrees = new Set(
- local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
- )
- const migrated = previousProjects.filter((project) => {
- if (!isRecord(project) || typeof project.worktree !== "string") return true
- if (worktrees.has(project.worktree)) return false
- worktrees.add(project.worktree)
- return true
- })
- const nextProjects: Record = { ...projects, local: [...local, ...migrated] }
- delete nextProjects[canonicalLocalServer]
- next.projects = nextProjects
- }
- if (lastProject && typeof previousLastProject === "string") {
- const nextLastProject = { ...lastProject }
- if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
- delete nextLastProject[canonicalLocalServer]
- next.lastProject = nextLastProject
- }
- return next
-}
-
-export function createServerProjects(input: {
- scope: Accessor
- store: Store
- setStore: SetStoreFunction
-}) {
- const setStore = input.setStore as unknown as SetStoreFunction
- const current = () => input.store.projects[input.scope()] ?? []
- const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
- const remove = (directory: string) => {
- setStore(
- "projects",
- input.scope(),
- current().filter((project) => project.worktree !== directory),
- )
- }
- return {
- list: current,
- recentlyClosed: currentClosed,
- remove,
- open(directory: string) {
- const scope = input.scope()
- const key = pathKey(directory)
- const closed = currentClosed()
- if (closed.some((worktree) => pathKey(worktree) === key)) {
- setStore(
- "recentlyClosed",
- scope,
- closed.filter((worktree) => pathKey(worktree) !== key),
- )
- }
- if (current().some((project) => project.worktree === directory)) return
- setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
- },
- // User-initiated close: removes the project and records it in recently closed.
- // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
- close(directory: string) {
- remove(directory)
- const key = pathKey(directory)
- const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
- 0,
- RECENTLY_CLOSED_HISTORY_LIMIT,
- )
- setStore("recentlyClosed", input.scope(), closed)
- },
- expand(directory: string) {
- const index = current().findIndex((project) => project.worktree === directory)
- if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
- },
- collapse(directory: string) {
- const index = current().findIndex((project) => project.worktree === directory)
- if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
- },
- move(directory: string, toIndex: number) {
- const fromIndex = current().findIndex((project) => project.worktree === directory)
- if (fromIndex === -1 || fromIndex === toIndex) return
- const next = [...current()]
- const [item] = next.splice(fromIndex, 1)
- next.splice(toIndex, 0, item)
- setStore("projects", input.scope(), next)
- },
- last() {
- return input.store.lastProject[input.scope()]
- },
- touch(directory: string) {
- setStore("lastProject", input.scope(), directory)
- },
- }
-}
-
-export function resolveServerList(input: {
- props?: Array
- stored: StoredServer[]
-}): Array {
- const deduped = new Map(
- input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
- )
-
- for (const value of input.stored) {
- const conn: ServerConnection.Http =
- typeof value === "string"
- ? {
- type: "http" as const,
- http: { url: value },
- }
- : "http" in value
- ? value
- : { type: "http", http: value }
- const key = ServerConnection.key(conn)
-
- const existing = deduped.get(key)
- if (existing)
- deduped.set(key, {
- ...existing,
- ...conn,
- http: { ...existing.http, ...conn.http },
- })
- else deduped.set(key, conn)
- }
-
- return [...deduped.values()]
-}
-
-export function canRemoveServer(input: {
- key: ServerConnection.Key
- provided?: Array
- stored: StoredServer[]
-}) {
- if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
- return input.stored.some((server) =>
- typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
- )
-}
-
-export namespace ServerConnection {
- type Base = { displayName?: string; label?: string }
-
- export type HttpBase = {
- url: string
- username?: string
- password?: string
- }
-
- // Regular web connections
- export type Http = {
- type: "http"
- http: HttpBase
- authToken?: boolean
- } & Base
-
- export type Sidecar = {
- type: "sidecar"
- http: HttpBase
- } & (
- | // Regular desktop server
- { variant: "base" }
- // WSL server (windows only)
- | {
- variant: "wsl"
- distro: string
- }
- ) &
- Base
-
- // Remote server desktop can SSH into
- export type Ssh = {
- type: "ssh"
- host: string
- // SSH client exposes an HTTP server for the app to use as a proxy
- http: HttpBase
- } & Base
-
- export type Any =
- | Http
- // All these are desktop-only
- | (Sidecar | Ssh)
-
- export const key = (conn: Any): Key => {
- switch (conn.type) {
- case "http":
- return Key.make(conn.http.url)
- case "sidecar": {
- if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
- return Key.make("sidecar")
- }
- case "ssh":
- return Key.make(`ssh:${conn.host}`)
- }
- }
-
- export type Key = string & { _brand: "Key" }
- export const Key = { make: (v: string) => v as Key }
-
- export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
- export const local = (conn?: Any) =>
- !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
-}
-
-export function nextServerAfterRemoval(
- servers: ServerConnection.Any[],
- removed: ServerConnection.Key,
- fallback: ServerConnection.Key,
-) {
- const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
- const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
- return next ? ServerConnection.key(next) : fallback
-}
+import { ServerConnection } from "./servers"
+import { useGlobal } from "./global"
+// Must be keyed for now
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
- gate: true,
- init: (props: {
- defaultServer: ServerConnection.Key
- canonicalLocalServer?: ServerConnection.Key
- servers?: Array
- }) => {
- const [store, setStore, _, ready] = persisted(
- {
- ...Persist.global("server", ["server.v3"]),
- migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
- },
- createStore({
- list: [] as StoredServer[],
- projects: {} as Record,
- lastProject: {} as Record,
- recentlyClosed: {} as Record,
- }),
- )
+ init: (props: { conn: ServerConnection.Any }) => {
+ const conn = props.conn
+ const key = ServerConnection.key(conn)
- const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
-
- const allServers = createMemo((): Array => {
- return resolveServerList({ stored: store.list, props: props.servers })
- })
-
- const [state, setState] = createStore({
- active: props.defaultServer,
- })
-
- function setActive(input: ServerConnection.Key) {
- if (state.active !== input) setState("active", input)
- }
-
- function add(input: ServerConnection.Http) {
- const url_ = normalizeServerUrl(input.http.url)
- if (!url_) return
- const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
- return batch(() => {
- const existing = store.list.findIndex((x) => url(x) === url_)
- if (existing !== -1) {
- setStore("list", existing, conn)
- } else {
- setStore("list", store.list.length, conn)
- }
- setState("active", ServerConnection.key(conn))
- return conn
- })
- }
-
- function remove(key: ServerConnection.Key) {
- const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
- const list = store.list.filter((x) => url(x) !== key)
- batch(() => {
- setStore("list", list)
- if (state.active === key) setState("active", next)
- })
- }
-
- function canRemove(key: ServerConnection.Key) {
- return canRemoveServer({ key, provided: props.servers, stored: store.list })
- }
-
- const isReady = Object.assign(
- createMemo(() => ready() && !!state.active),
- { promise: ready.promise },
- )
-
- const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
- const projects = createServerProjects({ scope, store, setStore })
- const projectStores = new Map>()
- const projectsForServer = (key: ServerConnection.Key) => {
- const existing = projectStores.get(key)
- if (existing) return existing
- const next = createServerProjects({ scope: () => scope(key), store, setStore })
- projectStores.set(key, next)
- return next
- }
- const current: Accessor = createMemo(
- () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
- )
- const isLocal = createMemo(() => ServerConnection.local(current()))
+ const global = useGlobal()
return {
- ready: isReady,
- isLocal,
- get key() {
- return state.active
- },
- get name() {
- return serverName(current())
- },
- get list() {
- return allServers()
- },
- get current() {
- return current()
- },
- setActive,
- add,
- remove,
- canRemove,
- scope,
- projects: {
- ...projects,
- forServer: projectsForServer,
+ conn,
+ key,
+ isLocal: ServerConnection.local(props.conn),
+ ctx: global.ensureServerCtx(conn),
+ get health() {
+ return global.servers.health[key]
},
}
},
diff --git a/packages/app/src/context/servers.tsx b/packages/app/src/context/servers.tsx
new file mode 100644
index 00000000000..13d1da6406d
--- /dev/null
+++ b/packages/app/src/context/servers.tsx
@@ -0,0 +1,342 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { type Accessor, batch, createMemo } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { pathKey } from "@/utils/path-key"
+import { ServerScope } from "@/utils/server-scope"
+
+type StoredProject = { worktree: string; expanded: boolean }
+type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
+type ServerProjectState = {
+ projects: Record
+ lastProject: Record
+ recentlyClosed: Record
+}
+const HEALTH_POLL_INTERVAL_MS = 10_000
+// The store retains more history than is displayed. Consumers filter recently closed entries
+// against the live project list (dropping deleted projects) and then cap the visible count via
+// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
+// filtered out do not evict still-visible ones from the persisted store.
+const RECENTLY_CLOSED_HISTORY_LIMIT = 16
+export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
+
+export function normalizeServerUrl(input: string) {
+ const trimmed = input.trim()
+ if (!trimmed) return
+ const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+ return withProtocol.replace(/\/+$/, "")
+}
+
+export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
+ if (!conn) return ""
+ if (conn.displayName && !ignoreDisplayName) return conn.displayName
+ return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
+}
+
+function isLocalHost(url: string) {
+ const host = url.replace(/^https?:\/\//, "").split(":")[0]
+ if (host === "localhost" || host === "127.0.0.1") return "local"
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
+ if (!canonicalLocalServer || canonicalLocalServer === "local") return value
+ if (!isRecord(value)) return value
+ const projects = isRecord(value.projects) ? value.projects : undefined
+ const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
+ const previousProjects = projects?.[canonicalLocalServer]
+ const previousLastProject = lastProject?.[canonicalLocalServer]
+ if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
+
+ const next = { ...value }
+ if (projects && Array.isArray(previousProjects)) {
+ const local = Array.isArray(projects.local) ? projects.local : []
+ const worktrees = new Set(
+ local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
+ )
+ const migrated = previousProjects.filter((project) => {
+ if (!isRecord(project) || typeof project.worktree !== "string") return true
+ if (worktrees.has(project.worktree)) return false
+ worktrees.add(project.worktree)
+ return true
+ })
+ const nextProjects: Record = { ...projects, local: [...local, ...migrated] }
+ delete nextProjects[canonicalLocalServer]
+ next.projects = nextProjects
+ }
+ if (lastProject && typeof previousLastProject === "string") {
+ const nextLastProject = { ...lastProject }
+ if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
+ delete nextLastProject[canonicalLocalServer]
+ next.lastProject = nextLastProject
+ }
+ return next
+}
+
+export function createServerProjects(input: {
+ scope: () => ServerScope
+ store: Store
+ setStore: SetStoreFunction
+}) {
+ const setStore = input.setStore as unknown as SetStoreFunction
+ const current = () => input.store.projects[input.scope()] ?? []
+ const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
+ const remove = (directory: string) => {
+ setStore(
+ "projects",
+ input.scope(),
+ current().filter((project) => project.worktree !== directory),
+ )
+ }
+ return {
+ list: current,
+ recentlyClosed: currentClosed,
+ remove,
+ open(directory: string) {
+ const scope = input.scope()
+ const key = pathKey(directory)
+ const closed = currentClosed()
+ if (closed.some((worktree) => pathKey(worktree) === key)) {
+ setStore(
+ "recentlyClosed",
+ scope,
+ closed.filter((worktree) => pathKey(worktree) !== key),
+ )
+ }
+ if (current().some((project) => project.worktree === directory)) return
+ setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
+ },
+ // User-initiated close: removes the project and records it in recently closed.
+ // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
+ close(directory: string) {
+ remove(directory)
+ const key = pathKey(directory)
+ const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
+ 0,
+ RECENTLY_CLOSED_HISTORY_LIMIT,
+ )
+ setStore("recentlyClosed", input.scope(), closed)
+ },
+ expand(directory: string) {
+ const index = current().findIndex((project) => project.worktree === directory)
+ if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
+ },
+ collapse(directory: string) {
+ const index = current().findIndex((project) => project.worktree === directory)
+ if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
+ },
+ move(directory: string, toIndex: number) {
+ const fromIndex = current().findIndex((project) => project.worktree === directory)
+ if (fromIndex === -1 || fromIndex === toIndex) return
+ const next = [...current()]
+ const [item] = next.splice(fromIndex, 1)
+ next.splice(toIndex, 0, item)
+ setStore("projects", input.scope(), next)
+ },
+ last() {
+ return input.store.lastProject[input.scope()]
+ },
+ touch(directory: string) {
+ setStore("lastProject", input.scope(), directory)
+ },
+ }
+}
+
+export function resolveServerList(input: {
+ props?: Array
+ stored: StoredServer[]
+}): Array {
+ const deduped = new Map(
+ input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
+ )
+
+ for (const value of input.stored) {
+ const conn: ServerConnection.Http =
+ typeof value === "string"
+ ? {
+ type: "http" as const,
+ http: { url: value },
+ }
+ : "http" in value
+ ? value
+ : { type: "http", http: value }
+ const key = ServerConnection.key(conn)
+
+ const existing = deduped.get(key)
+ if (existing)
+ deduped.set(key, {
+ ...existing,
+ ...conn,
+ http: { ...existing.http, ...conn.http },
+ })
+ else deduped.set(key, conn)
+ }
+
+ return [...deduped.values()]
+}
+
+export function canRemoveServer(input: {
+ key: ServerConnection.Key
+ provided?: Array
+ stored: StoredServer[]
+}) {
+ if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
+ return input.stored.some((server) =>
+ typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
+ )
+}
+
+export namespace ServerConnection {
+ type Base = { displayName?: string; label?: string }
+
+ export type HttpBase = {
+ url: string
+ username?: string
+ password?: string
+ }
+
+ // Regular web connections
+ export type Http = {
+ type: "http"
+ http: HttpBase
+ authToken?: boolean
+ } & Base
+
+ export type Sidecar = {
+ type: "sidecar"
+ http: HttpBase
+ } & (
+ | // Regular desktop server
+ { variant: "base" }
+ // WSL server (windows only)
+ | {
+ variant: "wsl"
+ distro: string
+ }
+ ) &
+ Base
+
+ // Remote server desktop can SSH into
+ export type Ssh = {
+ type: "ssh"
+ host: string
+ // SSH client exposes an HTTP server for the app to use as a proxy
+ http: HttpBase
+ } & Base
+
+ export type Any =
+ | Http
+ // All these are desktop-only
+ | (Sidecar | Ssh)
+
+ export const key = (conn: Any): Key => {
+ switch (conn.type) {
+ case "http":
+ return Key.make(conn.http.url)
+ case "sidecar": {
+ if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
+ return Key.make("sidecar")
+ }
+ case "ssh":
+ return Key.make(`ssh:${conn.host}`)
+ }
+ }
+
+ export type Key = string & { _brand: "Key" }
+ export const Key = { make: (v: string) => v as Key }
+
+ export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
+ export const local = (conn?: Any) =>
+ !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
+}
+
+export function nextServerAfterRemoval(
+ servers: ServerConnection.Any[],
+ removed: ServerConnection.Key,
+ fallback: ServerConnection.Key,
+) {
+ const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
+ const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
+ return next ? ServerConnection.key(next) : fallback
+}
+
+export const { use: useServers, provider: ServersProvider } = createSimpleContext({
+ name: "Server",
+ gate: true,
+ init: (props: {
+ defaultServer: ServerConnection.Key
+ canonicalLocalServer?: ServerConnection.Key
+ servers?: Array
+ }) => {
+ const [store, setStore, _] = persisted(
+ {
+ ...Persist.global("server", ["server.v3"]),
+ migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
+ },
+ createStore({
+ list: [] as StoredServer[],
+ projects: {} as Record,
+ lastProject: {} as Record,
+ recentlyClosed: {} as Record,
+ }),
+ )
+
+ const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
+
+ const allServers = createMemo((): Array => {
+ return resolveServerList({ stored: store.list, props: props.servers })
+ })
+
+ function add(input: ServerConnection.Http) {
+ const url_ = normalizeServerUrl(input.http.url)
+ if (!url_) return
+ const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
+ return batch(() => {
+ const existing = store.list.findIndex((x) => url(x) === url_)
+ if (existing !== -1) {
+ setStore("list", existing, conn)
+ } else {
+ setStore("list", store.list.length, conn)
+ }
+ return conn
+ })
+ }
+
+ function remove(key: ServerConnection.Key) {
+ const list = store.list.filter((x) => url(x) !== key)
+ batch(() => {
+ setStore("list", list)
+ })
+ }
+
+ function canRemove(key: ServerConnection.Key) {
+ return canRemoveServer({ key, provided: props.servers, stored: store.list })
+ }
+
+ const scope = (key: ServerConnection.Key) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
+ const projectStores = new Map>()
+ const projectsForServer = (key: ServerConnection.Key) => {
+ const existing = projectStores.get(key)
+ if (existing) return existing
+ const next = createServerProjects({ scope: () => scope(key), store, setStore })
+ projectStores.set(key, next)
+ return next
+ }
+
+ return {
+ get list() {
+ return allServers()
+ },
+ add,
+ remove,
+ canRemove,
+ scope,
+ projects: {
+ forServer: projectsForServer,
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 51058d49332..d8adf6a14b6 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -114,7 +114,7 @@ export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
- return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
+ return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory))
}
export type DirectorySync = ReturnType>
diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts
index 04c1cb6804a..32515515570 100644
--- a/packages/app/src/context/tab-migration.ts
+++ b/packages/app/src/context/tab-migration.ts
@@ -1,4 +1,4 @@
-import type { ServerConnection } from "./server"
+import type { ServerConnection } from "./servers"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts
index abd62a4dae1..86911b4e63d 100644
--- a/packages/app/src/context/tabs.test.ts
+++ b/packages/app/src/context/tabs.test.ts
@@ -4,7 +4,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
-import type { ServerConnection } from "./server"
+import type { ServerConnection } from "./servers"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index a98879c3e62..e256151447a 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/packages/app/src/context/tabs.tsx
@@ -2,7 +2,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
-import { ServerConnection, useServer } from "./server"
+import { ServerConnection, useServers } from "./servers"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
+import { useCurrentRoute } from "./layout"
export type SessionTab = {
type: "session"
@@ -54,13 +55,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
- const server = useServer()
+ const servers = useServers()
const platform = usePlatform()
- const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
- migrate: (value: unknown) => migrateTabs(value, fallback),
+ migrate: (value: unknown) => migrateTabs(value, ServerConnection.key(servers.list[0])),
},
createStore([]),
)
@@ -72,6 +72,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
+ const currentRoute = useCurrentRoute()
const closing = new Set()
let recentWrite = 0
@@ -120,11 +121,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!ready() || !recentReady()) return
- const servers = new Set(server.list.map(ServerConnection.key))
- const next = store.filter((tab) => servers.has(tab.server))
+ const serversSet = new Set(servers.list.map(ServerConnection.key))
+ const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
- if (!servers.has(tab.server)) {
+ if (!serversSet.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
@@ -141,8 +142,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!closedReady()) return
- const servers = new Set(server.list.map(ServerConnection.key))
- const next = closed.filter((entry) => servers.has(entry.tab.server))
+ const serversSet = new Set(servers.list.map(ServerConnection.key))
+ const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
@@ -293,10 +294,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) removeInfo(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
- if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
- const targetServer = input.server ?? server.key
+ const targetServer = input.server
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
.filter(
@@ -307,17 +307,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
- const currentHref =
- targetServer === server.key && params.dir && params.id
- ? tabHref({
- type: "session",
- server: targetServer,
- sessionId: params.id,
- })
- : undefined
- const currentIndex = currentHref
+ const route = currentRoute()
+ const sessionRoute = route.type === "session" ? route : undefined
+ const currentIndex = sessionRoute
? tabs.findIndex(
- (tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
+ (tab) =>
+ tab.type === "session" &&
+ tab.server === sessionRoute.server &&
+ tab.sessionId === sessionRoute.sessionId,
)
: -1
const currentTab = tabs[currentIndex]
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index d6904c46301..8593c2b53c9 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -440,7 +440,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const serverSDK = useServerSDK()
const params = useParams()
const cache = new Map()
- const scope = () => serverSDK().scope
+ const scope = () => serverSDK.scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache)
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index 6e5f63fd4e9..630fd5c8b31 100644
--- a/packages/app/src/entry.tsx
+++ b/packages/app/src/entry.tsx
@@ -10,7 +10,7 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
-import { ServerConnection } from "./context/server"
+import { ServerConnection } from "./context/servers"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
diff --git a/packages/app/src/hooks/provider-catalog.ts b/packages/app/src/hooks/provider-catalog.ts
index 01b966eda63..54c061202dc 100644
--- a/packages/app/src/hooks/provider-catalog.ts
+++ b/packages/app/src/hooks/provider-catalog.ts
@@ -1,6 +1,6 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
-const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
+export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
diff --git a/packages/app/src/hooks/use-integrations.ts b/packages/app/src/hooks/use-integrations.ts
index b69e2244078..4966624e896 100644
--- a/packages/app/src/hooks/use-integrations.ts
+++ b/packages/app/src/hooks/use-integrations.ts
@@ -10,8 +10,8 @@ export function useIntegrations(directory: Accessor) {
const query = createQuery(() => {
const value = directory()
return {
- ...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
- enabled: serverSDK().connection.status() === "connected",
+ ...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
+ enabled: serverSDK.connection.status() === "connected",
}
})
diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts
index db6862c1d50..21af866d0f7 100644
--- a/packages/app/src/hooks/use-providers.ts
+++ b/packages/app/src/hooks/use-providers.ts
@@ -1,7 +1,9 @@
-import { useServerSync } from "@/context/server-sync"
+import { useQueryOptions } from "@/context/server-sync"
import { Iterable, pipe } from "effect"
-import { createEffect, createMemo, type Accessor } from "solid-js"
-import { selectProviderCatalog } from "./provider-catalog"
+import { type Accessor } from "solid-js"
+import { emptyProviderCatalog } from "./provider-catalog"
+import { useQuery } from "@tanstack/solid-query"
+import { pathKey } from "@/utils/path-key"
export const popularProviders = [
"opencode",
@@ -16,31 +18,16 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor) {
- const serverSync = useServerSync()
- const dir = directory
- const providers = () => {
- const value = dir()
- const projectStore = value ? serverSync().child(value)[0] : undefined
- if (value)
- return selectProviderCatalog({
- explicit: true,
- directory: value,
- catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
- })
- return selectProviderCatalog({
- explicit: false,
- directory: value,
- catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
- global: serverSync().data.provider,
- })
- }
+ const providersQuery = useQuery(() => {
+ const queryOpts = useQueryOptions()
+ const dir = directory()
+ return queryOpts.providers(dir ? pathKey(dir) : null)
+ })
+
+ const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
return {
- ready: () => {
- const value = dir()
- if (!value) return false
- return serverSync().child(value)[0].provider_ready
- },
+ ready: () => providersQuery.isSuccess,
all: () => providers().all,
default: () => providers().default,
popular: () =>
diff --git a/packages/app/src/i18n/desktop-native.ts b/packages/app/src/i18n/desktop-native.ts
index 393dcfd2baa..d0dfb53594b 100644
--- a/packages/app/src/i18n/desktop-native.ts
+++ b/packages/app/src/i18n/desktop-native.ts
@@ -205,7 +205,7 @@ export function detectDesktopNativeLocale(languages: readonly string[]): Desktop
if (["no", "nb", "nn"].includes(source.language)) return "no"
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
- return target?.language === source.language && target.script === source.script
+ return target?.language === source.language && normalizeScript(target.script) === normalizeScript(source.script)
})
if (match) return match
}
@@ -224,6 +224,9 @@ function locale(value: string) {
}
}
+// Bun's ICU currently reports Punjabi's Arabic script as the invalid "Aran" code.
+const normalizeScript = (script?: string) => (script === "Aran" ? "Arab" : script)
+
export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.app": "OpenCode",
"desktop.menu.file": "File",
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 0e0ed0286f8..69f17eecada 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -2,7 +2,7 @@ export { AppBaseProviders, AppInterface } from "./app"
export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync"
-export { useServer } from "./context/server"
+export { useServers as useServers } from "./context/servers"
export { useSettings } from "./context/settings"
export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers"
@@ -26,5 +26,5 @@ export {
type WslServersPlatform,
type WslServersState,
} from "./wsl/types"
-export { ServerConnection } from "./context/server"
+export { ServerConnection } from "./context/servers"
export { createDraftStore, type DraftStore } from "./utils/draft-store"
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index b050e687f06..c359ee7681e 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -9,7 +9,7 @@ import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import { sessionHref } from "@/utils/session-route"
import { useServerSync } from "@/context/server-sync"
@@ -44,17 +44,14 @@ export function DirectoryDataProvider(
createResource(
() => params.id,
- (id) =>
- serverSync()
- .session.hydrate(id)
- .catch(() => {}),
+ (id) => serverSync.session.hydrate(id).catch(() => {}),
)
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
- serverSync().session.pin(sessionID)
- onCleanup(() => serverSync().session.unpin(sessionID))
+ serverSync.session.pin(sessionID)
+ onCleanup(() => serverSync.session.unpin(sessionID))
})
return (
diff --git a/packages/app/src/pages/home-session-archive.test.ts b/packages/app/src/pages/home-session-archive.test.ts
index 7f67651dc3c..1097ffcf88c 100644
--- a/packages/app/src/pages/home-session-archive.test.ts
+++ b/packages/app/src/pages/home-session-archive.test.ts
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { archiveHomeSession } from "./home-session-archive"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
const remote = "remote" as ServerConnection.Key
diff --git a/packages/app/src/pages/home-session-archive.ts b/packages/app/src/pages/home-session-archive.ts
index b8fa74e0421..7c4620224dc 100644
--- a/packages/app/src/pages/home-session-archive.ts
+++ b/packages/app/src/pages/home-session-archive.ts
@@ -1,5 +1,5 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import type { SessionInfo } from "@opencode-ai/client/promise"
type HomeSession = Pick
diff --git a/packages/app/src/pages/home/home-controller.ts b/packages/app/src/pages/home/home-controller.ts
index 5cb8635aab9..19540f8e869 100644
--- a/packages/app/src/pages/home/home-controller.ts
+++ b/packages/app/src/pages/home/home-controller.ts
@@ -1,32 +1,25 @@
-import { useGlobal } from "@/context/global"
+import { useGlobal, useServerCtx } from "@/context/global"
import { type HomeProjectSelection, useLayout } from "@/context/layout"
-import { ServerConnection, useServer } from "@/context/server"
-import { useServerSync } from "@/context/server-sync"
+import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
- const sync = useServerSync()
const layout = useLayout()
- const server = useServer()
const global = useGlobal()
const tabs = useTabs()
const selection = layout.home.selection
- const focusedServer = createMemo(
- () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
+ const focusedServer = createMemo(
+ () =>
+ global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
+ global.servers.list()[0],
)
- const focusedServerCtx = createMemo(() => {
- const conn = focusedServer()
- if (!conn) return undefined
- return global.ensureServerCtx(conn)
- })
- const focusedSync = () => focusedServerCtx()?.sync ?? sync()
- const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
- const recentlyClosed = createMemo(
- () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
- )
- const homedir = createMemo(() => focusedSync().data.path.home ?? "")
+ const focusedServerCtx = useServerCtx(focusedServer)
+ const focusedSync = () => focusedServerCtx()?.sync
+ const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? [])
+ const recentlyClosed = createMemo(() => focusedServerCtx()?.projects.recentlyClosed() ?? [])
+ const homedir = createMemo(() => focusedSync()?.data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
@@ -38,7 +31,7 @@ export function createHomeController() {
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
- const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
+ const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx
index f62907e203c..67111e0a1fe 100644
--- a/packages/app/src/pages/home/home-projects-controller.tsx
+++ b/packages/app/src/pages/home/home-projects-controller.tsx
@@ -6,7 +6,7 @@ import { type LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
@@ -14,15 +14,16 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
+import { useGlobal } from "@/context/global"
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const language = useLanguage()
- const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
+ const global = useGlobal()
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record }),
@@ -78,14 +79,14 @@ export function createHomeProjectsController(home: HomeController) {
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
- const state = notification.ensureServerState(ServerConnection.key(conn))
- return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
+ const notification = global.ensureServerCtx(conn).notification
+ return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
},
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
- const state = notification.ensureServerState(ServerConnection.key(conn))
+ const notification = global.ensureServerCtx(conn).notification
directories(project)
- .filter((directory) => state.project.unseenCount(directory) > 0)
- .forEach((directory) => state.project.markViewed(directory))
+ .filter((directory) => notification.project.unseenCount(directory) > 0)
+ .forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (home.server.health(conn)?.healthy === false) return
diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx
index 270bba49cb8..a4e99b877a1 100644
--- a/packages/app/src/pages/home/home-projects-view.tsx
+++ b/packages/app/src/pages/home/home-projects-view.tsx
@@ -12,7 +12,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts
index 6fec2ed0b5c..5d45193228b 100644
--- a/packages/app/src/pages/home/home-session-search-controller.ts
+++ b/packages/app/src/pages/home/home-session-search-controller.ts
@@ -1,6 +1,6 @@
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
-import { serverName } from "@/context/server"
+import { serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
import { sessionLabel } from "@/utils/session-title"
import { makeEventListener } from "@solid-primitives/event-listener"
diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx
index fb5a5431c2b..e29d6442622 100644
--- a/packages/app/src/pages/home/home-sessions-controller.tsx
+++ b/packages/app/src/pages/home/home-sessions-controller.tsx
@@ -1,7 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useQuery } from "@tanstack/solid-query"
+import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
import { produce } from "solid-js/store"
@@ -13,7 +13,7 @@ import {
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
@@ -51,41 +51,46 @@ export function createHomeSessionsController(home: HomeController) {
const projectByID = createMemo(
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
- const homeSessions = () => home.server.focusedSync().homeSessions
- const sessionEventLoad = useQuery(() => ({
- queryKey: homeSessions().eventsKey,
- queryFn: async (): Promise => ({ sequence: 0, entries: [] }),
- initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
- enabled: false,
- }))
- const sessionLoad = useQuery(() => ({
- queryKey: homeSessions().indexKey,
- enabled: home.server.focusedContext()?.sdk.connection.status() === "connected",
- queryFn: async ({ signal }) => {
- const ctx = home.server.focusedContext()
- if (!ctx) return { sessions: [], eventSequence: 0 }
- const cache = homeSessions()
- const eventSequence = cache.eventSequence()
- const index = await loadHomeSessionIndex(
- (input, options) => ctx.sdk.api.session.list(input, options),
- eventSequence,
- signal,
- )
- cache.complete(eventSequence)
- return index
- },
- retry: false,
- staleTime: 30_000,
- refetchOnMount: true,
- refetchOnReconnect: true,
- }))
- const indexedSessions = createMemo(() =>
- retainHomeSessions(
- homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
- HOME_SESSION_LIMIT,
- Date.now(),
- ),
- )
+ const homeSessions = () => home.server.focusedSync()?.homeSessions
+ const sessionEventLoad = useQuery(() => {
+ const cache = homeSessions()
+ return {
+ queryKey: cache?.eventsKey ?? (["home", "session-events", "unselected"] as const),
+ queryFn: cache ? async (): Promise => ({ sequence: 0, entries: [] }) : skipToken,
+ initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
+ enabled: false,
+ }
+ })
+ const sessionLoad = useQuery(() => {
+ const cache = homeSessions()
+ return {
+ queryKey: cache?.indexKey ?? (["home", "session-index", "unselected"] as const),
+ enabled: !!cache && home.server.focusedContext()?.sdk.connection.status() === "connected",
+ queryFn:
+ cache && home.server.focusedContext()
+ ? async ({ signal }) => {
+ const ctx = home.server.focusedContext()!
+ const eventSequence = cache.eventSequence()
+ const index = await loadHomeSessionIndex(
+ (input, options) => ctx.sdk.api.session.list(input, options),
+ eventSequence,
+ signal,
+ )
+ cache.complete(eventSequence)
+ return index
+ }
+ : skipToken,
+ retry: false,
+ staleTime: 30_000,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ }
+ })
+ const indexedSessions = createMemo(() => {
+ const cache = homeSessions()
+ if (!cache) return []
+ return retainHomeSessions(cache.sessions(sessionLoad.data, sessionEventLoad.data), HOME_SESSION_LIMIT, Date.now())
+ })
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
@@ -190,17 +195,18 @@ export function createHomeSessionsController(home: HomeController) {
) ?? projectForSession(session, home.project.list(), projectByID())
const conn = home.server.focused()
if (!conn) return
+ const connKey = ServerConnection.key(conn)
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
- tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+ tabs.addSessionTab({ server: connKey, sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
- const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+ const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
tabs.select(tab)
})
},
diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx
index 062cb606089..1d6133ba81a 100644
--- a/packages/app/src/pages/home/home-sessions-view.tsx
+++ b/packages/app/src/pages/home/home-sessions-view.tsx
@@ -7,7 +7,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionLabel } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index a2a073c3043..e23415cfeb3 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -23,7 +23,7 @@ import {
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
const serverKey = ServerConnection.Key.make
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index b2ca7bc8a31..e5ab005fd56 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -1,7 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { pathKey } from "@/utils/path-key"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import type { HomeProjectSelection } from "@/context/layout"
type SessionStore = {
diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts
index f603988ea28..dfdbfb115ec 100644
--- a/packages/app/src/pages/layout/project-avatar-state.ts
+++ b/packages/app/src/pages/layout/project-avatar-state.ts
@@ -1,9 +1,7 @@
import { createMemo, type Accessor } from "solid-js"
-import { useGlobal } from "@/context/global"
-import { useNotification } from "@/context/notification"
-import { usePermission } from "@/context/permission"
+import { useGlobal, useServerCtx } from "@/context/global"
import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
export function useSessionTabAvatarState(
server: Accessor,
@@ -11,20 +9,17 @@ export function useSessionTabAvatarState(
sessionId: Accessor,
) {
const global = useGlobal()
- const notification = useNotification()
- const permission = usePermission()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
- const sync = createMemo(() => {
- const conn = connection()
- if (conn) return global.ensureServerCtx(conn).sync
- })
+ const serverCtx = useServerCtx(connection)
+ const sync = () => serverCtx()?.sync
const hasPermissions = createMemo(() => {
- const serverSync = sync()
- if (!serverSync) return false
- const permissionState = permission.ensureServerState(server())
- const [store] = serverSync.child(directory(), { bootstrap: false })
- return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
- return !permissionState.autoResponds(item, directory())
+ const ctx = serverCtx()
+ if (!ctx) return false
+ const sync = ctx.sync
+ const permission = ctx.permission
+ const [store] = sync.child(directory(), { bootstrap: false })
+ return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
+ return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
@@ -34,11 +29,9 @@ export function useSessionTabAvatarState(
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
- const notificationState = createMemo(() => {
- if (!connection()) return
- return notification.ensureServerState(server())
- })
- const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0)
+ const unread = createMemo(
+ () => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
+ )
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx
index 09021736433..8a8ef28a5a3 100644
--- a/packages/app/src/pages/layout/session-tab-avatar.tsx
+++ b/packages/app/src/pages/layout/session-tab-avatar.tsx
@@ -1,6 +1,6 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx
index cbe2a91a4d9..9d4b83e5fd6 100644
--- a/packages/app/src/pages/new-session.tsx
+++ b/packages/app/src/pages/new-session.tsx
@@ -11,7 +11,7 @@ import { createNewSessionWorkspaceController } from "./new-session/new-session-w
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
-export default function NewSessionPage() {
+export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const [search] = useSearchParams<{ draftId?: string }>()
@@ -30,6 +30,7 @@ export default function NewSessionPage() {
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
+ draftId: props.draftId,
onSubmit: workspace.selection.remember,
})
const project = createPromptProjectController({
diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts
index 7063b9dc971..dbcd4a20dce 100644
--- a/packages/app/src/pages/new-session/new-session-draft-controller.ts
+++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts
@@ -10,9 +10,10 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
-export function createNewSessionDraftController(workspace: {
+export function createNewSessionDraftController(props: {
worktree: () => string
resetWorktree: () => void
+ draftId: string
onSubmit: () => void
}) {
const prompt = usePrompt()
@@ -28,20 +29,22 @@ export function createNewSessionDraftController(workspace: {
const controls = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
- queryOptions: serverSync().queryOptions,
+ queryOptions: serverSync.queryOptions,
model,
})
- const projectControls = createPromptProjectControls()
+ const projectControls = createPromptProjectControls({
+ draftId: props.draftId,
+ })
const input = usePromptInputV2Controller({
get controls() {
return controls()
},
get newSessionWorktree() {
- return workspace.worktree()
+ return props.worktree()
},
- onNewSessionWorktreeReset: workspace.resetWorktree,
+ onNewSessionWorktreeReset: props.resetWorktree,
onSubmit: () => {
- workspace.onSubmit()
+ props.onSubmit()
comments.clear()
},
})
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx
index 4d881f27a21..62acd2118b7 100644
--- a/packages/app/src/pages/new-session/new-session-view.tsx
+++ b/packages/app/src/pages/new-session/new-session-view.tsx
@@ -115,7 +115,7 @@ function ProviderTip() {
)
const visible = createMemo(
() =>
- serverSync().child(sdk().directory)[0].provider_ready &&
+ serverSync.child(sdk().directory)[0].provider_ready &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts
index 3fc44eca9e4..3b8b7601baf 100644
--- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts
+++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts
@@ -61,7 +61,7 @@ export function createNewSessionWorkspaceController(input: {
if (!project) return "main"
return workspaceDefaultSelection(
settings.workspaces.defaultDestination(),
- settings.workspaces.lastUsed(serverSDK().scope, project.id),
+ settings.workspaces.lastUsed(serverSDK.scope, project.id),
)
})
const value = createMemo(() =>
@@ -74,19 +74,19 @@ export function createNewSessionWorkspaceController(input: {
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
- const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
+ const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
const branch = createMemo(() =>
resolveNewSessionBranch({
worktree: value(),
local: localBranch(),
- worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
+ worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
}),
)
const remember = (worktree = value()) => {
const project = sync().project
if (!project) return
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
- settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
+ settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
return {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 9f1e1785b77..f5d98da8cc2 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -38,8 +38,8 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
+import { checksum } from "@opencode-ai/core/util/encode"
import { isWorkspaceDirectory } from "@/utils/workspace"
-import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
@@ -55,7 +55,7 @@ import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
-import { ServerConnection, serverName, useServer } from "@/context/server"
+import { ServerConnection, serverName, useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
@@ -145,14 +145,6 @@ async function runPromptRollbackMutation(input: {
})
}
-export function SessionPage() {
- return (
-
-
-
- )
-}
-
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
// remount around the server-scoped providers. Nothing here may key on the
// session ID: session tabs on the same server share this route instance, and
@@ -160,16 +152,19 @@ export function SessionPage() {
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync()
- const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
+ const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory)
return (
// Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary.
-
-
-
-
-
-
+ <>
+ params.id} />
+
+
+
+
+
+
+ >
)
}
@@ -181,17 +176,14 @@ function TargetSessionSettingsCommand() {
export function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
- const settings = useSettings()
return (
(
- }>
-
-
-
-
-
-
+
+
+
+
+
)}
>
{props.children}
@@ -201,16 +193,17 @@ export function SessionRouteErrorBoundary(
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
- const server = useServer()
+ const server = useServers()
const tabs = useTabs()
+
const displayServer = createMemo(() => {
- const key = props.serverKey ?? server.key
+ const key = props.serverKey
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return conn ? serverName(conn) : key
})
const closeTab = () => {
- if (!props.sessionID) return
- tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
+ if (!props.sessionID || !props.serverKey) return
+ tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID })
}
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
return (
@@ -249,7 +242,7 @@ function ResolvedTargetSessionRoute() {
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const current = createSessionLineage(
() => params.id,
- () => sync().session.lineage,
+ () => sync.session.lineage,
)
const directory = createMemo(() => current()?.session.location.directory)
@@ -286,25 +279,24 @@ function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
-
-
+
+
+
+
+
+
+
+
+
+
)
}
-function TargetServerScopedProviders(props: ParentProps<{ directory?: string; sessionID?: string }>) {
- return (
- <>
-
- {props.children}
- >
- )
-}
-
-function MarkSessionNotificationsViewed(props: { sessionID?: string }) {
+function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
- const sessionID = props.sessionID
+ const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
@@ -312,18 +304,6 @@ function MarkSessionNotificationsViewed(props: { sessionID?: string }) {
return null
}
-function SessionProviders(props: ParentProps) {
- return (
-
-
-
- {props.children}
-
-
-
- )
-}
-
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
@@ -404,49 +384,11 @@ export default function Page() {
const inputController = createPromptInputController({
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
- queryOptions: serverSync().queryOptions,
+ queryOptions: serverSync.queryOptions,
})
- const workspaceTabs = createMemo(() => layout.tabs(controller.identity.workspaceKey))
const sessionPanelKey = createMemo(() =>
- controller.identity.params.id ? `${serverSDK().scope}\0${controller.identity.params.id}` : undefined,
- )
-
- createEffect(
- on(
- () => controller.identity.params.id,
- (id, prev) => {
- if (!id) return
- if (prev) return
-
- const pending = layout.handoff.tabs()
- if (!pending) return
- if (Date.now() - pending.at > 60_000) {
- layout.handoff.clearTabs()
- return
- }
- if (pending.scope !== serverSDK().scope) return
-
- if (pending.id !== id) return
- layout.handoff.clearTabs()
- if (pending.dir !== base64Encode(sdk().directory)) return
-
- const from = workspaceTabs().tabs()
- if (from.all.length === 0 && !from.active) return
-
- const current = controller.layout.tabs().tabs()
- if (current.all.length > 0 || current.active) return
-
- const all = controller.tabs.normalizeAll(from.all)
- const active = from.active ? controller.tabs.normalize(from.active) : undefined
- controller.layout.tabs().setAll(all)
- controller.layout.tabs().setActive(active && all.includes(active) ? active : all[0])
-
- workspaceTabs().setAll([])
- workspaceTabs().setActive(undefined)
- },
- { defer: true },
- ),
+ controller.identity.params.id ? `${serverSDK.scope}\0${controller.identity.params.id}` : undefined,
)
const size = createSizing()
@@ -580,7 +522,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
- Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
+ Persist.serverWorkspace(serverSDK.scope, sdk().directory, "followup", ["followup.v1"]),
createStore<{
items: Record
failed: Record
@@ -656,7 +598,7 @@ export default function Page() {
const vcsKey = createMemo(
() =>
[
- serverSDK().scope,
+ serverSDK.scope,
"session-vcs",
sdk().directory,
sync().data.vcs?.branch ?? "",
@@ -665,7 +607,7 @@ export default function Page() {
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
- const enabled = serverSDK().connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
+ const enabled = serverSDK.connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
@@ -681,9 +623,8 @@ export default function Page() {
}
})
const sessionDetailsQuery = createQuery(() => ({
- queryKey: [serverSDK().scope, "session-details", sessionDirectory()] as const,
- enabled:
- store.sessionDetailsOpen && serverSDK().connection.status() === "connected" && sync().project?.vcs === "git",
+ queryKey: [serverSDK.scope, "session-details", sessionDirectory()] as const,
+ enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && sync().project?.vcs === "git",
queryFn: () =>
sdk()
.api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" })
@@ -696,7 +637,7 @@ export default function Page() {
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : undefined)
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
- void queryClient.invalidateQueries({ queryKey: [serverSDK().scope, "session-details", sessionDirectory()] })
+ void queryClient.invalidateQueries({ queryKey: [serverSDK.scope, "session-details", sessionDirectory()] })
}, 100)
onCleanup(
sdk().event.listen((event) => {
@@ -746,7 +687,7 @@ export default function Page() {
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
- queryKey: [...vcsKey(), mode, "directory", scope, context, version] as const,
+ queryKey: [serverSDK.scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
@@ -841,11 +782,11 @@ export default function Page() {
}
function upsert(next: Project) {
- const list = serverSync().data.project
+ const list = serverSync.data.project
sync().set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
- serverSync().set(
+ serverSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
@@ -853,10 +794,10 @@ export default function Page() {
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
- serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
+ serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
- serverSync().set("project", [...list, next])
+ serverSync.set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
@@ -1736,7 +1677,7 @@ export default function Page() {
const ok = await sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
- serverSync: serverSync(),
+ serverSync: serverSync,
session: () => sync().session.get(input.sessionID),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts
index c46ef6ddf82..4d2476c5606 100644
--- a/packages/app/src/pages/session/composer/session-composer-controls.ts
+++ b/packages/app/src/pages/session/composer/session-composer-controls.ts
@@ -5,12 +5,12 @@ import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input/contracts"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
-import { useGlobal } from "@/context/global"
+import { useGlobal, useServerCtx } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
-import { serverName, ServerConnection, useServer } from "@/context/server"
+import { serverName, ServerConnection, useServers } from "@/context/servers"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
@@ -55,21 +55,18 @@ export function createPromptInputController(input: {
})
}
-export function createPromptProjectControls() {
- const navigate = useNavigate()
- const layout = useLayout()
- const server = useServer()
+export function createPromptProjectControls(props: { draftId: string }) {
+ const server = useServers()
const serverSDK = useServerSDK()
const sdk = useSDK()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
- const [search] = useSearchParams<{ draftId?: string }>()
- const projectServer = () => serverSDK().server
- const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
+ const projectServer = () => serverSDK.server
+ const projectServerCtx = useServerCtx(projectServer)
const projects = createMemo(() => {
if (server.list.length <= 1) {
- return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
+ return projectServerCtx().projects.list()
}
return server.list.flatMap((conn) => {
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
@@ -81,28 +78,12 @@ export function createPromptProjectControls() {
})
const selectProject = (worktree: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
- if (search.draftId) {
- if (!conn) return
- const target = global.ensureServerCtx(conn)
- target.projects.open(worktree)
- target.projects.touch(worktree)
- tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
- return
- }
-
- if (!serverKey) {
- layout.projects.open(worktree)
- server.projects.touch(worktree)
- navigate(`/${base64Encode(worktree)}/session`)
- return
- }
-
if (!conn) return
+
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
- server.setActive(ServerConnection.key(conn))
- navigate(`/${base64Encode(worktree)}/session`)
+ tabs.updateDraft(props.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
}
const addProject = (title: string, serverKey?: string) => {
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
index 44a707214ee..9baa69eddb4 100644
--- a/packages/app/src/pages/session/composer/session-composer-state.ts
+++ b/packages/app/src/pages/session/composer/session-composer-state.ts
@@ -37,10 +37,10 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const queryClient = useQueryClient()
const language = useLanguage()
const permission = usePermission()
- const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const
+ const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
const shells = createQuery(() => ({
queryKey: shellKey(),
- enabled: !!params.id && serverSDK().connection.status() === "connected",
+ enabled: !!params.id && serverSDK.connection.status() === "connected",
queryFn: () =>
sdk()
.api.shell.list({ location: { directory: sdk().directory } })
@@ -59,7 +59,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
)
const questionRequest = createMemo((): FormInfo | undefined => {
- return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id)
+ return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -77,7 +77,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
- return serverSync().session.data.todo[id] ?? []
+ return serverSync.session.data.todo[id] ?? []
})
const done = createMemo(
@@ -87,13 +87,13 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const primary = () => {
const id = params.id
- return !!id && !serverSync().session.get(id)?.parentID
+ return !!id && !serverSync.session.get(id)?.parentID
}
const backgroundBlocking = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
- const assistant = (serverSync().session.data.session_message[id] ?? []).findLast(
+ const assistant = (serverSync.session.data.session_message[id] ?? []).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined,
)
if (assistant?.type !== "assistant") return []
@@ -116,7 +116,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const id = params.id
if (!id) return []
const blocking = backgroundBlocking()
- const messages = serverSync().session.data.session_message[id] ?? []
+ const messages = serverSync.session.data.session_message[id] ?? []
const completed = new Set(
messages.flatMap((message) => {
if (message.type !== "synthetic") return []
@@ -144,9 +144,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
]
})
})
- const active = Object.values(serverSync().session.data.info).flatMap((info) => {
+ const active = Object.values(serverSync.session.data.info).flatMap((info) => {
if (info?.parentID !== id) return []
- if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
+ if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
if (
blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
index 61c6289a750..3b0dd8f5004 100644
--- a/packages/app/src/pages/session/composer/session-question-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-question-dock.tsx
@@ -71,7 +71,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
- const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
+ const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() =>
props.request.fields.filter(questionField).map((field) => ({
diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts
index 1adf6c1a967..199635735de 100644
--- a/packages/app/src/pages/session/session-layout.ts
+++ b/packages/app/src/pages/session/session-layout.ts
@@ -10,7 +10,7 @@ export const useSessionKey = () => {
const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK()
- const scope = createMemo(() => serverSDK().scope)
+ const scope = createMemo(() => serverSDK.scope)
const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
diff --git a/packages/app/src/pages/session/timeline/controller.tsx b/packages/app/src/pages/session/timeline/controller.tsx
index ffa1d787b25..63f56040b63 100644
--- a/packages/app/src/pages/session/timeline/controller.tsx
+++ b/packages/app/src/pages/session/timeline/controller.tsx
@@ -21,6 +21,7 @@ import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
+import { useServer } from "@/context/server"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
@@ -46,6 +47,7 @@ export function createTimelineController(input: {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
+ const server = useServer()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
@@ -195,7 +197,7 @@ export function createTimelineController(input: {
void navigateAfterRemoval(id, session.parentID, next?.id)
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
removed.forEach((sessionID) => sync().session.evict(sessionID))
- notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
+ notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 59d94be0d56..9a0a3f61d5c 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -1469,7 +1469,7 @@ function MessageTimelineView(
directory={sessionDirectory()}
local={!workspaceSession()}
branch={sync().data.vcs?.branch}
- baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
+ baseBranch={serverSync.child(project().worktree)[0].vcs?.branch}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts
index e28d1345ada..8a905f8764a 100644
--- a/packages/app/src/pages/session/timeline/model.ts
+++ b/packages/app/src/pages/session/timeline/model.ts
@@ -24,7 +24,7 @@ export function createTimelineModel(input: { session: Pick sync().data.message[id] !== undefined)
- const stale = cached && !serverSync().session.fresh(id, sessionFreshness)
+ const stale = cached && !serverSync.session.fresh(id, sessionFreshness)
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
@@ -42,7 +42,7 @@ export function createTimelineModel(input: { session: Pick {
const id = input.session.identity.sessionID()
- return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
+ return !id || isTimelineReady(sync().data.message[id], serverSync.session.history.loading(id))
})
const more = createMemo(() => {
const id = input.session.identity.sessionID()
diff --git a/packages/app/src/pages/session/v2/review-panel-v2-state.ts b/packages/app/src/pages/session/v2/review-panel-v2-state.ts
index 645055e107e..587b55c7089 100644
--- a/packages/app/src/pages/session/v2/review-panel-v2-state.ts
+++ b/packages/app/src/pages/session/v2/review-panel-v2-state.ts
@@ -6,9 +6,10 @@ import {
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
+import type { Platform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
-export function createReviewPanelV2State() {
+export function createReviewPanelV2State(platform?: Platform) {
const [store, setStore, , ready] = persisted(
Persist.global("review-panel-v2"),
createStore({
@@ -16,6 +17,7 @@ export function createReviewPanelV2State() {
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
expandMode: "collapse" as SessionReviewExpandMode,
}),
+ platform,
)
// The filter is transient by design: a persisted filter would silently hide
// files after a reload.
diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
index b5570283269..18ee78a13fd 100644
--- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
+++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
@@ -14,6 +14,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { pathKey } from "@/utils/path-key"
+import { useServer } from "@/context/server"
const emptyFiles: string[] = []
@@ -39,6 +40,7 @@ export function SessionFileBrowserTab(props: {
const language = useLanguage()
const layout = useLayout()
const sdk = useSDK()
+ const server = useServer()
const serverSDK = useServerSDK()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
@@ -49,8 +51,8 @@ export function SessionFileBrowserTab(props: {
const search = createQuery(() => {
const value = query()
return {
- queryKey: [serverSDK().scope, "session-open-file", workspaceKey(), value] as const,
- enabled: serverSDK().connection.status() === "connected" && value.length > 0,
+ queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
+ enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
}
})
@@ -65,10 +67,11 @@ export function SessionFileBrowserTab(props: {
if (explicit && values.includes(explicit)) return explicit
return values[0]
})
+
const loading = createMemo(() => query().length > 0 && search.isPending)
const project = createMemo(() => {
const directory = pathKey(sdk().directory)
- return layout.projects
+ return server.ctx.projects
.list()
.find(
(item) =>
diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts
index 97d5559b726..2b6da620c54 100644
--- a/packages/app/src/utils/server-health.test.ts
+++ b/packages/app/src/utils/server-health.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts
index fdd0df08989..28e183c37cf 100644
--- a/packages/app/src/utils/server-health.ts
+++ b/packages/app/src/utils/server-health.ts
@@ -1,5 +1,5 @@
import { usePlatform } from "@/context/platform"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { authTokenFromCredentials } from "./server"
import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
diff --git a/packages/app/src/utils/server-scope.ts b/packages/app/src/utils/server-scope.ts
index 9a4e941d389..07d8507ecfb 100644
--- a/packages/app/src/utils/server-scope.ts
+++ b/packages/app/src/utils/server-scope.ts
@@ -1,4 +1,4 @@
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
export type ServerScope = string & { readonly __brand: "ServerScope" }
export type SessionRouteKey = string & { readonly __brand: "SessionRouteKey" }
diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts
index 68f6bdca848..27006d151db 100644
--- a/packages/app/src/utils/server.ts
+++ b/packages/app/src/utils/server.ts
@@ -1,5 +1,5 @@
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
-import type { ServerConnection } from "@/context/server"
+import type { ServerConnection } from "@/context/servers"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts
index 6a87e366cf8..576a27d9608 100644
--- a/packages/app/src/utils/session-route.test.ts
+++ b/packages/app/src/utils/session-route.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts
index 4721611d2ea..12e1226f512 100644
--- a/packages/app/src/utils/session-route.ts
+++ b/packages/app/src/utils/session-route.ts
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx
index 22b302e51ae..62c981c9f32 100644
--- a/packages/app/src/wsl/settings.tsx
+++ b/packages/app/src/wsl/settings.tsx
@@ -11,7 +11,7 @@ import type { ServerCollectionController } from "@/components/server/server-mana
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { showToast } from "@/utils/toast"
import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context"
diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts
index a8d9acf7a61..a74b078e67d 100644
--- a/packages/app/test-browser/command-palette.test.ts
+++ b/packages/app/test-browser/command-palette.test.ts
@@ -4,7 +4,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
const stored: Project = {
diff --git a/packages/app/test-browser/prompt-scope.test.ts b/packages/app/test-browser/prompt-scope.test.ts
index fad19dcff77..a8d077e3c16 100644
--- a/packages/app/test-browser/prompt-scope.test.ts
+++ b/packages/app/test-browser/prompt-scope.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
-import { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/servers"
import { selectPromptTab } from "@/context/prompt"
import type { Tab } from "@/context/tabs"
diff --git a/packages/app/test-browser/review-panel-v2-state.test.ts b/packages/app/test-browser/review-panel-v2-state.test.ts
index 2a7862ce171..5f1073d2982 100644
--- a/packages/app/test-browser/review-panel-v2-state.test.ts
+++ b/packages/app/test-browser/review-panel-v2-state.test.ts
@@ -1,6 +1,7 @@
import { beforeAll, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
+import type { Platform } from "@/context/platform"
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
let read: ((value: string | null) => void) | undefined
@@ -15,15 +16,21 @@ const storage: AsyncStorage = {
length: Promise.resolve(0),
}
+const platform: Platform = {
+ platform: "desktop",
+ storage: () => storage,
+ openExternal: () => undefined,
+ restart: async () => undefined,
+ notify: async () => undefined,
+ openDirectoryPickerDialog: async () => null,
+}
+
beforeAll(async () => {
mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
}))
- mock.module("@/context/platform", () => ({
- usePlatform: () => ({ platform: "desktop", storage: () => storage }),
- }))
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
})
@@ -31,15 +38,10 @@ beforeAll(async () => {
test("enables sidebar motion only after custom width hydration", async () => {
await new Promise((resolve, reject) => {
createRoot((dispose) => {
- const state = createReviewPanelV2State()
- const transition =
- "sidebarTransition" in state && typeof state.sidebarTransition === "function"
- ? (state.sidebarTransition as () => boolean)
- : undefined
+ const state = createReviewPanelV2State(platform)
try {
- expect(transition).toBeFunction()
- expect(transition?.()).toBeFalse()
+ expect(state.sidebarTransition()).toBeFalse()
expect(state.sidebarWidth()).toBe(240)
} catch (error) {
dispose()
@@ -48,7 +50,7 @@ test("enables sidebar motion only after custom width hydration", async () => {
}
createEffect(() => {
- if (!transition?.()) return
+ if (!state.sidebarTransition()) return
try {
expect(state.sidebarWidth()).toBe(360)
dispose()
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 3a79dfe9a72..586e82703d7 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -406,18 +406,12 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
}>
{(key) => (
-
- }
- >
+
+
)}
diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx
index 4f9c3540c28..2e56d8a15a3 100644
--- a/packages/desktop/src/renderer/onboarding.tsx
+++ b/packages/desktop/src/renderer/onboarding.tsx
@@ -1,8 +1,12 @@
-import { ServerConnection, useServer, useTabs } from "@opencode-ai/app"
+import { ServerConnection, useServers, useTabs } from "@opencode-ai/app"
import { onMount } from "solid-js"
-export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
- const server = useServer()
+export function DesktopFirstLaunchOnboarding(props: {
+ serverKey: ServerConnection.Key
+ initialUrl: string
+ onLoaded: () => void
+}) {
+ const server = useServers()
const tabs = useTabs()
onMount(() => {
@@ -11,11 +15,8 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
async function runFirstLaunchOnboarding() {
try {
- await Promise.all(
- [server.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()),
- )
+ await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
const existingInstall = await window.api.isOldLayoutEligible()
- if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending()
if (!pending) return
@@ -39,9 +40,10 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
if (!shouldTrigger || !directory) return
console.info("[desktop-onboarding] starting first launch draft", { directory })
- server.projects.open(directory)
- server.projects.touch(directory)
- tabs.select(await tabs.newDraft({ server: server.key, directory }))
+ const projects = server.projects.forServer(props.serverKey)
+ projects.open(directory)
+ projects.touch(directory)
+ tabs.select(await tabs.newDraft({ server: props.serverKey, directory }))
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
}
diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx
index 15c5ca9d75a..21c3a39c2f6 100644
--- a/packages/session-ui/src/v2/components/prompt-input/index.tsx
+++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx
@@ -162,7 +162,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
- class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
+ class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,