From c6f684366aab90a5cd532bad4b5ad9390e9bf3bd Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:25:45 +0800 Subject: [PATCH] feat(app): add servers tab to settings dialog (#29675) --- packages/app/src/app.tsx | 6 +- .../components/dialog-connect-provider.tsx | 2 + packages/app/src/components/dialog-fork.tsx | 2 +- .../src/components/dialog-manage-models.tsx | 1 + .../components/dialog-select-directory.tsx | 21 +- .../app/src/components/dialog-select-file.tsx | 1 + .../app/src/components/dialog-select-mcp.tsx | 1 + .../components/dialog-select-model-unpaid.tsx | 4 +- .../src/components/dialog-select-model.tsx | 2 +- .../src/components/dialog-select-provider.tsx | 1 + .../src/components/dialog-select-server.tsx | 376 ++++++++++-------- .../app/src/components/dialog-settings.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 4 +- .../session/session-new-design-view.tsx | 2 +- .../app/src/components/settings-models.tsx | 14 +- .../app/src/components/settings-providers.tsx | 12 +- .../src/components/settings-server-picker.tsx | 106 +++++ .../app/src/components/settings-servers.tsx | 33 ++ .../settings-v2/dialog-settings-v2.tsx | 8 + .../src/components/status-popover-body.tsx | 154 +++---- .../app/src/components/status-popover.tsx | 18 +- packages/app/src/context/global.tsx | 248 ++++++++++++ packages/app/src/context/server-sdk.tsx | 23 +- packages/app/src/context/server-sync.tsx | 42 +- packages/app/src/context/servers.tsx | 20 - packages/app/src/context/settings.tsx | 4 + packages/app/src/pages/home.tsx | 211 +++++----- packages/app/src/pages/layout.tsx | 4 +- packages/app/src/pages/session.tsx | 2 +- packages/ui/src/components/list.css | 2 +- 30 files changed, 921 insertions(+), 413 deletions(-) create mode 100644 packages/app/src/components/settings-server-picker.tsx create mode 100644 packages/app/src/components/settings-servers.tsx create mode 100644 packages/app/src/context/global.tsx delete mode 100644 packages/app/src/context/servers.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9554a6363..915c8ec53 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -33,6 +33,7 @@ import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" +import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" @@ -47,7 +48,6 @@ import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { ServersProvider } from "./context/servers" const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -316,7 +316,7 @@ export function AppInterface(props: { }) { return ( - + @@ -337,7 +337,7 @@ export function AppInterface(props: { - + ) } diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 6cd8456e8..4d477ea27 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -277,6 +277,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{select()?.message}
x.value} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} @@ -364,6 +365,7 @@ export function DialogConnectProvider(props: { provider: string }) {
{ listRef = ref }} diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 8e17f27d3..d0d105e07 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -88,7 +88,7 @@ export const DialogFork: Component = () => { return ( x.id} diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index ace79e38a..b46034bba 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -39,6 +39,7 @@ export const DialogManageModels: Component = () => { } > `${x?.provider?.id}:${x?.id}`} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index c7c3b5098..4eb5ba4b5 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -6,15 +6,16 @@ import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLayout } from "@/context/layout" +import { ServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useGlobal } from "@/context/global" interface DialogSelectDirectoryProps { title?: string multiple?: boolean onSelect: (result: string | string[] | null) => void + server: ServerConnection.Any } type Row = { @@ -127,11 +128,7 @@ function uniqueRows(rows: Row[]) { }) } -function useDirectorySearch(args: { - sdk: ReturnType - start: () => string | undefined - home: () => string -}) { +function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) { const cache = new Map>>() let current = 0 @@ -246,9 +243,8 @@ function useDirectorySearch(args: { } export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const sync = useServerSync() - const sdk = useServerSDK() - const layout = useLayout() + const global = useGlobal() + const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) const dialog = useDialog() const language = useLanguage() @@ -279,7 +275,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }) const recentProjects = createMemo(() => { - const projects = layout.projects.list() + const projects = serverCtx.projects.list() const byProject = new Map() for (const project of projects) { @@ -324,6 +320,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return ( { description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })} > x?.name ?? ""} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index f916ef623..fae743bb8 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
{language.t("dialog.model.unpaid.freeModels.title")}
(listRef = ref)} items={model.list} current={model.current()} @@ -90,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
{language.t("dialog.model.unpaid.addMore.title")}
p.id} items={providers.popular} activeIcon="plus-small" diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index fdef866a7..ca2643c3b 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -37,7 +37,7 @@ const ModelList: Component<{ return ( `${x.provider.id}:${x.id}`} diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 1273db596..89310286d 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => { return ( defaultKey.latest, canDefault, setDefault } } function useServerPreview() { @@ -121,7 +123,7 @@ function ServerForm(props: ServerFormProps) { } return ( -
+
+
+ }> + + +
+
+ ) +} + +export function useServerManagementController(options: { onSelect?: () => void } = {}) { + const navigate = useNavigate() const server = useServer() + const global = useGlobal() const platform = usePlatform() const language = useLanguage() const { defaultKey, canDefault, setDefault } = useDefaultServer() const { previewStatus } = useServerPreview() const checkServerHealth = useCheckServerHealth() const [store, setStore] = createStore({ - status: {} as Record, addServer: { url: "", name: "", @@ -311,7 +327,12 @@ export function DialogSelectServer() { return [current, ...list.filter((x) => x !== current)] }) - const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + const settings = useSettings() + const current = createMemo(() => + settings.general.newLayoutDesigns() + ? undefined + : (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]), + ) const sortedItems = createMemo(() => { const list = items() @@ -326,32 +347,16 @@ export function DialogSelectServer() { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) + const diff = + rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) }) - async function refreshHealth() { - const results: Record = {} - await Promise.all( - items().map(async (conn) => { - results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) - }), - ) - setStore("status", reconcile(results)) - } - - createEffect(() => { - items() - void refreshHealth() - const interval = setInterval(refreshHealth, 10_000) - onCleanup(() => clearInterval(interval)) - }) - async function select(conn: ServerConnection.Any, persist?: boolean) { - if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return - dialog.close() + if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return + options.onSelect?.() if (persist && conn.type === "http") { server.add(conn) navigate("/") @@ -457,7 +462,7 @@ export function DialogSelectServer() { username: conn.http.username ?? "", password: conn.http.password ?? "", error: "", - status: store.status[ServerConnection.key(conn)]?.healthy, + status: global.servers.health[ServerConnection.key(conn)]?.healthy, }) } @@ -502,148 +507,183 @@ export function DialogSelectServer() { } } + return { + defaultKey, + canDefault, + current, + sortedItems, + status: () => global.servers.health, + isFormMode, + isAddMode, + formTitle, + formBusy, + formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value), + formName: () => (isAddMode() ? store.addServer.name : store.editServer.name), + formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username), + formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password), + formError: () => (isAddMode() ? store.addServer.error : store.editServer.error), + formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status), + select, + setDefault, + startAdd, + startEdit, + resetForm, + submitForm, + handleRemove, + handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange), + handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange), + handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange), + handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange), + } +} + +export function ServerConnectionList(props: { controller: ReturnType }) { + const language = useLanguage() + const settings = useSettings() + return ( - -
- - } +
+ x.http.url} + onSelect={(x) => { + if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) + }} + divider={true} + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + + } + showCredentials + /> +
+ + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + props.controller.startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + + + + props.controller.setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + props.controller.handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + + +
+
+ ) + }} +
+ +
+ - } - > - - -
+ {language.t("dialog.server.add.button")} +
-
+
+ ) +} + +export function ServerConnectionForm(props: { controller: ReturnType }) { + const language = useLanguage() + + return ( +
+ +
+ +
+
) } diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f..20d71f4bf 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" +import { SettingsServers } from "./settings-servers" export const DialogSettings: Component = () => { const language = useLanguage() @@ -17,7 +18,7 @@ export const DialogSettings: Component = () => { -
+
@@ -31,6 +32,10 @@ export const DialogSettings: Component = () => { {language.t("settings.tab.shortcuts")} + + + {language.t("status.popover.tab.servers")} +
@@ -61,6 +66,9 @@ export const DialogSettings: Component = () => { + + + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 84fe7495f..8a5ab87d2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1363,6 +1363,8 @@ export const PromptInput: Component = (props) => { navigate(`/${base64Encode(worktree)}/session`) } const addProject = async () => { + const conn = server.current + if (!conn) return const select = (result: string | string[] | null) => { const directory = Array.isArray(result) ? result[0] : result if (!directory) return @@ -1374,7 +1376,7 @@ export const PromptInput: Component = (props) => { } void import("@/components/dialog-select-directory").then((x) => { dialog.show( - () => , + () => , () => select(null), ) }) diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx index b1192db0e..6993b8f5a 100644 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -4,7 +4,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" export function NewSessionDesignView(props: { children: JSX.Element }) { return ( -
+
diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 14667338e..f3d9e1522 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" import { SettingsList } from "./settings-list" +import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" type ModelItem = ReturnType["list"]>[number] @@ -32,6 +33,14 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) = } export const SettingsModels: Component = () => { + return ( + + + + ) +} + +const SettingsModelsContent: Component = () => { const language = useLanguage() const models = useModels() @@ -61,7 +70,10 @@ export const SettingsModels: Component = () => {
-

{language.t("settings.models.title")}

+
+

{language.t("settings.models.title")}

+ +
["connected"]>[number] @@ -28,6 +29,14 @@ const PROVIDER_NOTES = [ ] as const export const SettingsProviders: Component = () => { + return ( + + + + ) +} + +const SettingsProvidersContent: Component = () => { const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() @@ -129,8 +138,9 @@ export const SettingsProviders: Component = () => { return (
-
+

{language.t("settings.providers.title")}

+
diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx new file mode 100644 index 000000000..3f679753f --- /dev/null +++ b/packages/app/src/components/settings-server-picker.tsx @@ -0,0 +1,106 @@ +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { QueryClientProvider } from "@tanstack/solid-query" +import { createMemo, For, type ParentProps, Show } from "solid-js" +import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { ModelsProvider } from "@/context/models" +import { ServerConnection } from "@/context/server" +import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSyncProvider } from "@/context/server-sync" +import { useGlobal } from "@/context/global" +import { useSettings } from "@/context/settings" + +export function SettingsServerScope(props: ParentProps) { + const global = useGlobal() + const settings = useSettings() + + return ( + + + {(server) => {props.children}} + + + ) +} + +function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) { + const global = useGlobal() + const serverCtx = () => global.createServerCtx(props.server) + + return ( + + + + {props.children} + + + + ) +} + +export function SettingsServerPicker() { + const global = useGlobal() + const settings = useSettings() + const selected = createMemo(() => + settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined, + ) + + return ( + + {(conn) => ( + + + + + + + + + { + if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key)) + }} + > + + {(item) => { + const key = ServerConnection.key(item) + const blocked = () => global.servers.health[key]?.healthy === false + return ( + + + + + + + + ) + }} + + + + + + )} + + ) +} diff --git a/packages/app/src/components/settings-servers.tsx b/packages/app/src/components/settings-servers.tsx new file mode 100644 index 000000000..299d41f6d --- /dev/null +++ b/packages/app/src/components/settings-servers.tsx @@ -0,0 +1,33 @@ +import { Show, type Component } from "solid-js" +import { useLanguage } from "@/context/language" +import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server" + +export const SettingsServers: Component = () => { + const language = useLanguage() + const controller = useServerManagementController() + + return ( +
+
+ +
+
+

{language.t("status.popover.tab.servers")}

+
+
+ + + } + > +
+
{controller.formTitle()}
+ +
+
+
+
+ ) +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index d574dcf49..7408767b9 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -9,6 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds" import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" +import { SettingsServers } from "../settings-servers" export const DialogSettings: Component = () => { const language = useLanguage() @@ -38,6 +39,10 @@ export const DialogSettings: Component = () => { {language.t("settings.tab.shortcuts")} + + + {language.t("status.popover.tab.servers")} +
@@ -68,6 +73,9 @@ export const DialogSettings: Component = () => { + + + diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 80933b812..7f2184558 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -17,7 +17,8 @@ import { useSync } from "@/context/sync" import { type ServerHealth } from "@/utils/server-health" import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { useServers } from "@/context/servers" +import { useGlobal } from "@/context/global" +import { useSettings } from "@/context/settings" const pollMs = 10_000 @@ -153,7 +154,7 @@ type ServerStatusItem = { } export function StatusPopoverServerBody() { - const servers = useServers() + const global = useGlobal() const server = useServer() const platform = usePlatform() const dialog = useDialog() @@ -167,7 +168,7 @@ export function StatusPopoverServerBody() { dialogRun += 1 }) - const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health)) + const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) const defaultServer = useDefaultServerKey(platform.getDefaultServer) const serverItems = createMemo(() => sortedServers().map((conn) => { @@ -175,8 +176,8 @@ export function StatusPopoverServerBody() { return { key, conn, - health: servers.health[key], - blocked: servers.health[key]?.healthy === false, + health: global.servers.health[key], + blocked: global.servers.health[key]?.healthy === false, active: !!server.current && key === ServerConnection.key(server.current), onSelect: () => { navigate("/") @@ -288,12 +289,13 @@ function ServerStatusList(props: { state: ServerStatusState }) { export function StatusPopoverBody(props: { shown: Accessor }) { const sync = useSync() - const servers = useServers() + const global = useGlobal() const server = useServer() const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() + const settings = useSettings() const fail = (err: unknown) => { showToast({ @@ -313,7 +315,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { dialogDead = true dialogRun += 1 }) - const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health)) + const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) @@ -333,15 +335,17 @@ export function StatusPopoverBody(props: { shown: Accessor }) { aria-label={language.t("status.popover.ariaLabel")} class="tabs bg-background-strong rounded-xl overflow-hidden" data-component="tabs" - data-active="servers" - defaultValue="servers" + data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"} + defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"} variant="alt" > - - {servers.list().length > 0 ? `${servers.list().length} ` : ""} - {language.t("status.popover.tab.servers")} - + {!settings.general.newLayoutDesigns() && ( + + {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""} + {language.t("status.popover.tab.servers")} + + )} {mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {language.t("status.popover.tab.mcp")} @@ -356,70 +360,72 @@ export function StatusPopoverBody(props: { shown: Accessor }) { - -
-
- - {(s) => { - const key = ServerConnection.key(s) - const blocked = () => servers.health[key]?.healthy === false - return ( - - ) - }} - + + + + {language.t("common.default")} + + + } + > +
+ + + + + + ) + }} + - + +
-
-
+ + )}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c602fbb00..6b42e62e5 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -7,7 +7,7 @@ import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid- import { useLanguage } from "@/context/language" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { useServers } from "@/context/servers" +import { useGlobal } from "@/context/global" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -15,10 +15,10 @@ const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ def export function StatusPopover() { const language = useLanguage() const server = useServer() - const servers = useServers() + const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready) + const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready) const mcpIssue = createMemo(() => { const mcp = Object.values(sync.data.mcp ?? {}) const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") @@ -26,8 +26,8 @@ export function StatusPopover() { if (failed) return "critical" as const if (warn) return "warning" as const }) - const serverHealthy = () => servers.health[server.key]?.healthy === true - const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue()) + const serverHealthy = () => global.servers.health[server.key]?.healthy === true + const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) return ( servers.health[server.key]?.healthy + const serverHealth = () => global.servers.health[server.key]?.healthy const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready) const mcpIssue = createMemo(() => { const mcp = Object.values(sync.data.mcp ?? {}) @@ -116,9 +116,9 @@ function DirectoryStatusPopover() { function ServerStatusPopover() { const language = useLanguage() const server = useServer() - const servers = useServers() + const global = useGlobal() const [shown, setShown] = createSignal(false) - const serverHealth = () => servers.health[server.key]?.healthy + const serverHealth = () => global.servers.health[server.key]?.healthy const state = createMemo(() => ({ shown: shown(), ready: serverHealth() !== undefined, diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx new file mode 100644 index 000000000..77673bacb --- /dev/null +++ b/packages/app/src/context/global.tsx @@ -0,0 +1,248 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createEffect, createMemo, createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { ServerConnection, useServer } from "./server" +import { useServerHealth } from "@/utils/server-health" +import { QueryClient } from "@tanstack/solid-query" +import { createServerSdkContext } from "./server-sdk" +import { createServerSyncContext } from "./server-sync" +import { getOwner } from "solid-js/web" +import { Persist, persisted } from "@/utils/persist" + +export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ + name: "Global", + init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { + const server = useServer() + const serverHealth = useServerHealth( + () => server.list, + () => true, + ) + const [store, setStore] = createStore({ + settings: { + serverKey: undefined as ServerConnection.Key | undefined, + }, + }) + + const serversAndProjects = createServersAndProjectStore() + + const settingsServer = createMemo(() => { + const list = server.list + return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0] + }) + + createEffect(() => { + const conn = settingsServer() + const key = conn ? ServerConnection.key(conn) : undefined + if (store.settings.serverKey !== key) setStore("settings", "serverKey", key) + }) + + const serverCtxs = new Map< + ServerConnection.Key, + { dispose: () => void; serverCtx: ReturnType } + >() + + const owner = getOwner() + + createMemo(() => { + for (const conn of server.list) { + const key = ServerConnection.key(conn) + if (!serverCtxs.has(key)) { + const root = createRoot((dispose) => { + const serverCtx = createServerCtx(conn, serversAndProjects) + return { dispose, serverCtx } + }, owner as any) + serverCtxs.set(key, root) + } + } + + for (const [key] of serverCtxs) { + if (!server.list.find((conn) => ServerConnection.key(conn) === key)) { + const { dispose } = serverCtxs.get(key)! + dispose() + serverCtxs.delete(key) + } + } + }) + + const allServers = createMemo( + (): Array => + resolveServerList({ stored: serversAndProjects.store.list, props: props.servers }), + ) + + return { + servers: { + list: allServers, + health: serverHealth, + }, + settings: { + server: { + get key() { + return store.settings.serverKey + }, + selected: settingsServer, + set(key: ServerConnection.Key) { + if (store.settings.serverKey !== key) setStore("settings", "serverKey", key) + }, + }, + }, + createServerCtx(conn: ServerConnection.Any) { + const key = ServerConnection.key(conn) + const ctx = serverCtxs.get(key) + if (!ctx) return createServerCtx(conn, serversAndProjects) + return ctx.serverCtx + }, + } + }, +}) + +type StoredProject = { worktree: string; expanded: boolean } +type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http + +const createServersAndProjectStore = () => { + const [store, setStore, _, ready] = persisted( + Persist.global("server", ["server.v3"]), + createStore({ + list: [] as StoredServer[], + projects: {} as Record, + lastProject: {} as Record, + }), + ) + return { store, setStore, ready } +} + +function createServerCtx( + conn: ServerConnection.Any, + { store, setStore }: ReturnType, +) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnReconnect: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, + }) + + const sdk = createServerSdkContext(conn) + const sync = createServerSyncContext(sdk) + + const key = ServerConnection.key(conn) + const storeKey = projectsKey(key) + + function enrich(project: { worktree: string; expanded: boolean }) { + const [childStore] = sync.child(project.worktree, { bootstrap: false }) + const projectID = childStore.project + const metadata = projectID + ? sync.data.project.find((x) => x.id === projectID) + : sync.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 + } + + const projectsList = createMemo(() => (store.projects[storeKey] ?? []).map(enrich)) + + const isLocal = + (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) + + return { + queryClient, + sdk, + sync, + isLocal, + projects: { + list: projectsList, + open(directory: string) { + const current = store.projects[storeKey] ?? [] + if (current.find((x) => x.worktree === directory)) return + setStore("projects", storeKey, [{ worktree: directory, expanded: true }, ...current]) + }, + close(directory: string) { + const current = store.projects[storeKey] ?? [] + setStore( + "projects", + storeKey, + current.filter((x) => x.worktree !== directory), + ) + }, + expand(directory: string) { + const current = store.projects[storeKey] ?? [] + const index = current.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", storeKey, index, "expanded", true) + }, + collapse(directory: string) { + const current = store.projects[storeKey] ?? [] + const index = current.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", storeKey, index, "expanded", false) + }, + move(directory: string, toIndex: number) { + const current = store.projects[storeKey] ?? [] + const fromIndex = current.findIndex((x) => x.worktree === directory) + if (fromIndex === -1 || fromIndex === toIndex) return + const result = [...current] + const [item] = result.splice(fromIndex, 1) + result.splice(toIndex, 0, item) + setStore("projects", storeKey, result) + }, + last() { + return store.lastProject[storeKey] + }, + touch(directory: string) { + setStore("lastProject", storeKey, directory) + }, + }, + } +} + +export type ServerCtx = ReturnType + +function isLocalHost(url: string) { + const host = url.replace(/^https?:\/\//, "").split(":")[0] + if (host === "localhost" || host === "127.0.0.1") return "local" +} + +function projectsKey(key: ServerConnection.Key) { + if (key === "sidecar") return "local" + if (isLocalHost(key)) return "local" + return key +} + +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()] +} diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 47446b2d8..98fdbfa89 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -8,11 +8,12 @@ import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" +import { useGlobal } from "./global" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" -function createServerSdkContext(server: ServerConnection.Any) { +export function createServerSdkContext(server: ServerConnection.Any) { const platform = usePlatform() const abort = new AbortController() @@ -244,18 +245,22 @@ function createServerSdkContext(server: ServerConnection.Any) { } } +export type ServerSDK = ReturnType + export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({ name: "ServerSDK", - init: () => { + init: (props: { server?: ServerConnection.Any }) => { + const global = useGlobal() const language = useLanguage() const server = useServer() - if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable")) - const sdk = createServerSdkContext(server.current) - return { - ...sdk, - createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), - } + const conn = props.server ?? server.current + if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) + + const ctx = global.createServerCtx(conn) + return Object.assign(ctx.sdk, { + createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)), + }) }, }) @@ -263,7 +268,7 @@ type SDKEventMap = { [key in Event["type"]]: Extract } -function createDirSdkContext(directory: string, serverSDK: ReturnType) { +function createDirSdkContext(directory: string, serverSDK: ServerSDK) { const client = serverSDK.createClient({ directory, throwOnError: true, diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index a572ec274..823a965dc 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,21 @@ import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" +import { + batch, + createContext, + createEffect, + getOwner, + onCleanup, + onMount, + type ParentProps, + untrack, + useContext, +} from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" -import { useServerSDK } from "./server-sdk" +import { ServerSDK, useServerSDK } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, @@ -31,6 +41,8 @@ import { PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context" import { createRefCountMap } from "@/utils/refcount" +import { useGlobal } from "./global" +import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" type GlobalStore = { @@ -74,8 +86,8 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path } export type QueryOptionsApi = ReturnType -export function createServerSyncContext() { - const serverSDK = useServerSDK() +export function createServerSyncContext(_serverSDK?: ServerSDK) { + const serverSDK: ServerSDK = _serverSDK ?? useServerSDK() const language = useLanguage() const owner = getOwner() if (!owner) throw new Error("ServerSync must be created within owner") @@ -105,7 +117,7 @@ export function createServerSyncContext() { const [globalStore, setGlobalStore] = createStore({ get ready() { - return bootstrap.isPending + return !bootstrap.isPending }, project: [], session_todo: {}, @@ -128,6 +140,7 @@ export function createServerSyncContext() { return updateConfigMutation.isPending ? "pending" : undefined }, }) + const queryClient = useQueryClient() let bootedAt = 0 @@ -465,17 +478,22 @@ export function createServerSyncContext() { export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({ name: "ServerSync", - init: () => { - const sync = createServerSyncContext() + init: (props: { server?: ServerConnection.Any }) => { + const global = useGlobal() + const language = useLanguage() + const server = useServer() - return { - ...sync, + const conn = props.server ?? server.current + if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable")) + const ctx = global.createServerCtx(conn) + + return Object.assign(ctx.sync, { createDirSyncContext: createRefCountMap( - (dir) => createDirSyncContext(dir, sync), - (dir) => sync.disableMcp(dir), + (dir) => createDirSyncContext(dir, ctx.sync), + (dir) => ctx.sync.disableMcp(dir), directoryKey, ), - } + }) }, }) diff --git a/packages/app/src/context/servers.tsx b/packages/app/src/context/servers.tsx deleted file mode 100644 index 18baf7f0e..000000000 --- a/packages/app/src/context/servers.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { createSimpleContext } from "@opencode-ai/ui/context" -import { useServer } from "./server" -import { useServerHealth } from "@/utils/server-health" - -export const { use: useServers, provider: ServersProvider } = createSimpleContext({ - name: "Servers", - init: () => { - const server = useServer() - - const health = useServerHealth( - () => server.list, - () => true, - ) - - return { - list: () => server.list, - health, - } - }, -}) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 84288d31a..cad0c6055 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -159,6 +159,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont init: () => { const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + createEffect(() => { + console.log("settings", { ready: ready() }) + }) + createEffect(() => { if (typeof document === "undefined") return const root = document.documentElement diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 807ffd68c..e7bc61ce0 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -23,7 +23,6 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectServer } from "@/components/dialog-select-server" import { ServerConnection, useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" -import { useServers } from "@/context/servers" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" @@ -32,6 +31,7 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { messageAgentColor } from "@/utils/agent" import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" +import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" import { useSettings } from "@/context/settings" import { ServerHealthIndicator } from "@/components/server/server-row" @@ -116,6 +116,7 @@ function HomeDesign() { const navigate = useNavigate() const server = useServer() const language = useLanguage() + const global = useGlobal() const command = useCommand() const notification = useNotification() let focusSessionSearch: (() => void) | undefined @@ -187,8 +188,9 @@ function HomeDesign() { setState("project", state.project === directory ? undefined : directory) } - function addProject(directory: string) { - layout.projects.open(directory) + function addProject(conn: ServerConnection.Any, directory: string) { + const server = global.createServerCtx(conn) + server.projects.open(directory) server.projects.touch(directory) setState("project", directory) } @@ -196,7 +198,8 @@ function HomeDesign() { function openNewSession() { const project = selectedProject() if (!project) { - void chooseProject() + const conn = server.current + if (conn) void chooseProject(conn) return } layout.projects.open(project.worktree) @@ -233,17 +236,19 @@ function HomeDesign() { navigate(`/${base64Encode(session.directory)}/session/${session.id}`) } - async function chooseProject() { + async function chooseProject(conn: ServerConnection.Any) { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { - result.forEach(addProject) + result.forEach((r) => addProject(conn, r)) if (result[0]) setState("project", result[0]) return } - if (result) addProject(result) + if (result) addProject(conn, result) } - if (platform.openDirectoryPickerDialog && server.isLocal()) { + const server = global.createServerCtx(conn) + + if (platform.openDirectoryPickerDialog && server.isLocal) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, @@ -253,7 +258,7 @@ function HomeDesign() { } dialog.show( - () => , + () => , () => resolve(null), ) } @@ -265,72 +270,77 @@ function HomeDesign() { } return ( -
- void chooseProject()} - editProject={editProject} - closeProject={(directory) => { - layout.projects.close(directory) - if (state.project === directory) setState("project", undefined) - }} - clearNotifications={clearNotifications} - unseenCount={unseenCount} - openSettings={openSettings} - openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} - language={language} - /> - -
- { - focusSessionSearch = focus +
+
+ void chooseProject(conn)} + editProject={editProject} + closeProject={(directory) => { + layout.projects.close(directory) + if (state.project === directory) setState("project", undefined) }} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} + clearNotifications={clearNotifications} + unseenCount={unseenCount} + openSettings={openSettings} + openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + language={language} /> -
-
- }> + +
+ { + focusSessionSearch = focus + }} + onInput={(value) => setState("search", value)} + onFocus={() => setState("searchFocused", true)} + onClose={closeSearch} + onSelect={selectSearchSession} + /> +
+
0} - fallback={ -
- -
- } + when={!sessionLoad.isLoading} + fallback={} > - - {(group, index) => ( + 0} + fallback={
- -
- - {(record) => } - -
+
- )} -
+ } + > + + {(group, index) => ( +
+ +
+ + {(record) => } + +
+
+ )} +
+
- +
-
-
+ +
) } @@ -340,7 +350,7 @@ function HomeProjectColumn(props: { selected?: string selectProject: (directory: string) => void openNewSession: (directory: string) => void - chooseProject: () => void + chooseProject: (server: ServerConnection.Any) => void editProject: (project: LocalProject) => void closeProject: (directory: string) => void clearNotifications: (project: LocalProject) => void @@ -349,27 +359,33 @@ function HomeProjectColumn(props: { openHelp: () => void language: ReturnType }) { - const servers = useServers() + const global = useGlobal() return (