From ae14e78ba6ab64b3483c5b6cbaad251433be6c1b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:25:08 +0800 Subject: [PATCH] refactor(app): consolidate desktop server data contexts (#42256) --- .../e2e/regression/review-open-file.spec.ts | 16 +- packages/app/src/app.tsx | 324 +++--------- .../app/src/components/command-palette.ts | 5 +- .../components/dialog-command-palette-v2.tsx | 2 +- .../components/dialog-connect-provider.tsx | 2 +- .../src/components/dialog-custom-provider.tsx | 4 +- .../src/components/dialog-edit-project-v2.tsx | 2 +- .../components/dialog-select-directory-v2.tsx | 2 +- .../components/dialog-select-directory.tsx | 2 +- .../app/src/components/dialog-select-file.tsx | 8 +- .../src/components/dialog-select-server.tsx | 23 +- .../src/components/directory-picker-policy.ts | 2 +- .../app/src/components/directory-picker.tsx | 2 +- packages/app/src/components/edit-project.ts | 2 +- .../project-settings-extensions.tsx | 4 +- packages/app/src/components/prompt-input.tsx | 1 - .../components/prompt-input/submit.test.ts | 10 +- .../app/src/components/prompt-input/submit.ts | 10 +- .../provider-connection-controller.ts | 20 +- .../server/server-management-controller.ts | 41 +- .../server/server-management.test.ts | 7 +- .../components/server/server-management.ts | 7 +- .../src/components/server/server-row-menu.tsx | 2 +- .../app/src/components/server/server-row.tsx | 2 +- .../src/components/session-context-usage.tsx | 3 +- .../src/components/session-workspace-menu.tsx | 8 +- .../src/components/session/open-in-app.tsx | 4 +- .../src/components/session/session-header.tsx | 494 +----------------- .../app/src/components/settings-keybinds.tsx | 322 +----------- .../src/components/settings-server-picker.tsx | 13 +- .../settings-v2/dialog-server-v2.tsx | 9 +- .../settings-v2/dialog-settings-v2.tsx | 47 +- .../src/components/settings-v2/extensions.tsx | 12 +- .../settings-v2/general-controllers.ts | 31 +- .../src/components/settings-v2/general.tsx | 12 +- .../app/src/components/settings-v2/models.tsx | 2 +- .../settings-v2/parts/server-select.tsx | 2 +- .../src/components/settings-v2/projects.tsx | 2 +- .../src/components/settings-v2/providers.tsx | 8 +- .../src/components/settings-v2/servers.tsx | 2 +- .../src/components/settings-v2/workspaces.tsx | 12 +- .../src/components/status-popover-body.tsx | 13 +- .../app/src/components/status-popover.tsx | 11 +- .../titlebar-session-events.test.ts | 2 +- .../src/components/titlebar-session-events.ts | 9 +- .../app/src/components/titlebar-tab-nav.tsx | 15 +- .../app/src/components/titlebar-tab-strip.tsx | 9 +- packages/app/src/components/titlebar.tsx | 258 ++------- packages/app/src/context/comments.tsx | 2 +- packages/app/src/context/file.tsx | 8 +- packages/app/src/context/global.tsx | 49 +- packages/app/src/context/layout.tsx | 350 ++++--------- packages/app/src/context/local.tsx | 10 +- packages/app/src/context/models.tsx | 300 ++++++----- packages/app/src/context/notification.tsx | 171 +----- packages/app/src/context/permission.tsx | 143 +---- packages/app/src/context/platform.tsx | 2 +- packages/app/src/context/prompt.tsx | 8 +- packages/app/src/context/route.ts | 0 packages/app/src/context/sdk.tsx | 7 +- packages/app/src/context/server-sdk.tsx | 28 +- packages/app/src/context/server-sync.tsx | 32 +- packages/app/src/context/server.test.ts | 108 +--- packages/app/src/context/server.tsx | 378 +------------- packages/app/src/context/servers.tsx | 342 ++++++++++++ packages/app/src/context/sync.tsx | 2 +- packages/app/src/context/tab-migration.ts | 2 +- packages/app/src/context/tabs.test.ts | 2 +- packages/app/src/context/tabs.tsx | 39 +- packages/app/src/context/terminal.tsx | 2 +- packages/app/src/entry.tsx | 2 +- packages/app/src/hooks/provider-catalog.ts | 2 +- packages/app/src/hooks/use-integrations.ts | 4 +- packages/app/src/hooks/use-providers.ts | 39 +- packages/app/src/i18n/desktop-native.ts | 5 +- packages/app/src/index.ts | 4 +- packages/app/src/pages/directory-layout.tsx | 11 +- .../src/pages/home-session-archive.test.ts | 2 +- .../app/src/pages/home-session-archive.ts | 2 +- .../app/src/pages/home/home-controller.ts | 31 +- .../pages/home/home-projects-controller.tsx | 15 +- .../app/src/pages/home/home-projects-view.tsx | 2 +- .../home/home-session-search-controller.ts | 2 +- .../pages/home/home-sessions-controller.tsx | 84 +-- .../app/src/pages/home/home-sessions-view.tsx | 2 +- packages/app/src/pages/layout/helpers.test.ts | 2 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../src/pages/layout/project-avatar-state.ts | 35 +- .../src/pages/layout/session-tab-avatar.tsx | 2 +- packages/app/src/pages/new-session.tsx | 3 +- .../new-session-draft-controller.ts | 15 +- .../pages/new-session/new-session-view.tsx | 2 +- .../new-session-workspace-controller.ts | 8 +- packages/app/src/pages/session.tsx | 157 ++---- .../composer/session-composer-controls.ts | 37 +- .../composer/session-composer-state.ts | 18 +- .../composer/session-question-dock.tsx | 2 +- .../app/src/pages/session/session-layout.ts | 2 +- .../src/pages/session/timeline/controller.tsx | 4 +- .../session/timeline/message-timeline.tsx | 2 +- .../app/src/pages/session/timeline/model.ts | 4 +- .../pages/session/v2/review-panel-v2-state.ts | 4 +- .../session/v2/session-file-browser-tab.tsx | 9 +- packages/app/src/utils/server-health.test.ts | 2 +- packages/app/src/utils/server-health.ts | 2 +- packages/app/src/utils/server-scope.ts | 2 +- packages/app/src/utils/server.ts | 2 +- packages/app/src/utils/session-route.test.ts | 2 +- packages/app/src/utils/session-route.ts | 2 +- packages/app/src/wsl/settings.tsx | 2 +- .../app/test-browser/command-palette.test.ts | 2 +- .../app/test-browser/prompt-scope.test.ts | 2 +- .../review-panel-v2-state.test.ts | 24 +- packages/desktop/src/renderer/index.tsx | 18 +- packages/desktop/src/renderer/onboarding.tsx | 22 +- .../src/v2/components/prompt-input/index.tsx | 2 +- 116 files changed, 1279 insertions(+), 3105 deletions(-) create mode 100644 packages/app/src/context/route.ts create mode 100644 packages/app/src/context/servers.tsx diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index ccdfcef0407..c50da2293f5 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => { const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() - await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible() await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(sidebarToggle).toBeDisabled() await expect(sidebar).toBeVisible() await contextButton.click() - await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible() await expect(sidebar).toBeHidden() await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() await panel.getByRole("button", { name: "README.md" }).click() - await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible() await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(sidebar).toHaveCount(0) @@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => { expect(resultID).toBeTruthy() await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await filter.press("Enter") - await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible() await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible() await expect(sidebarToggle).toBeDisabled() await panel.locator("#session-side-panel-review-tab").click() await expect(sidebarToggle).toBeEnabled() await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) - await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible() }) function fileNode(path: string) { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5c9ca5fe43f..abb9decee7b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,44 +6,33 @@ import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { File } from "@opencode-ai/session-ui/file" import { Font } from "@opencode-ai/ui/font" -import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { Effect } from "effect" import { type Component, createEffect, createMemo, createRenderEffect, - createResource, - createSignal, ErrorBoundary, - For, type JSX, lazy, - onCleanup, type ParentProps, Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" -import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" -import { NotificationProvider } from "@/context/notification" -import { PermissionProvider } from "@/context/permission" import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" -import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" +import { ServerConnection, ServersProvider, useServers } from "@/context/servers" import { SettingsProvider } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider } from "@/context/sdk" @@ -51,26 +40,26 @@ import { WslServersProvider } from "@/wsl/context" import { DirectoryDataProvider } from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" -import { useCheckServerHealth } from "./utils/server-health" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { decode64 } from "@/utils/base64" -import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" +import { TargetSessionRouteContent } from "@/pages/session" import { Home } from "@/pages/home" +import { ServerProvider } from "./context/server" const NewSession = lazy(() => import("@/pages/new-session")) const DirectoryDraftRedirect = () => { const params = useParams() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() - const server = useServer() + const server = useServers() const tabs = useTabs() createEffect(() => { if (search.draftId || !tabs.ready()) return const directory = decode64(params.dir) if (!directory) return - tabs.newDraft({ server: server.key, directory }, search.prompt) + tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt) }) return null @@ -87,36 +76,13 @@ function TargetServerRoute(props: ParentProps) { return ( // Owns the server-identity remount. Session changes must not remount this subtree. - - {props.children} - + + {(conn) => {props.children}} + ) } -function TargetSessionRoute() { - const params = useParams<{ serverKey: string; id: string }>() - return ( - - - - - - ) -} - -// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell for that server. -function SelectedServerProviders(props: ParentProps) { - return ( - - - {props.children} - - - ) -} - function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() @@ -139,19 +105,21 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { return ( - - - - - - - - - - - - - + + {(conn) => ( + + + + + + + + + + + + )} + ) } @@ -190,8 +158,8 @@ function QueryProvider(props: ParentProps) { defaultOptions: { queries: { refetchOnReconnect: false, - refetchOnMount: false, refetchOnWindowFocus: false, + refetchOnMount: false, }, }, }) @@ -211,18 +179,6 @@ function BodyDesignClass() { // Server-agnostic providers shared across every route. These live in the shared // shell (router root) so they stay mounted regardless of the active server/route. -function SharedProviders(props: ParentProps) { - return ( - <> - - - - {props.children} - - - ) -} - function DesktopCommands() { const command = useCommand() const language = useLanguage() @@ -246,30 +202,14 @@ function DesktopCommands() { return null } -type ServerScopedShellProps = ParentProps<{ - directory?: string - serverScoped?: JSX.Element -}> - -function ServerScopedProviders(props: ServerScopedShellProps) { +function AppLayout(props: ParentProps) { return ( - {props.serverScoped} - {props.children} + {props.children} ) } -function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { - return ( - - - {props.children} - - - ) -} - // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -319,133 +259,6 @@ export function AppBaseProviders( ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { - const server = useServer() - const checkServerHealth = useCheckServerHealth() - - const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") - - // performs repeated health check with a grace period for - // non-http connections, otherwise fails instantly - const [startupHealthCheck, healthCheckActions] = createResource(() => - props.disableHealthCheck - ? true - : Effect.gen(function* () { - if (!server.current) return true - const { http, type } = server.current - - while (true) { - const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true - if (checkMode() === "background" || type === "http") return false - } - }).pipe( - Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }), - Effect.ensuring(Effect.sync(() => setCheckMode("background"))), - Effect.runPromise, - ), - ) - const checking = createMemo( - () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), - ) - const [startup] = createResource(async () => { - if (!props.startup) return true - await props.startup.catch((error) => { - console.error("[startup] startup gate failed", error) - }) - return true - }) - const startupChecking = createMemo( - () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), - ) - const loading = createMemo(() => checking() || startupChecking()) - - return ( - <> - - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} - - - -
- -
-
- - ) -} - -function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { - const language = useLanguage() - const server = useServer() - const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) - const name = createMemo(() => server.name || server.key) - const serverToken = "\u0000server\u0000" - const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) - - const timer = setInterval(() => props.onRetry?.(), 1000) - onCleanup(() => clearInterval(timer)) - - return ( -
-
- -

- {unreachable()[0]} - {name()} - {unreachable()[1]} -

-

{language.t("app.server.retrying")}

-
- 0}> -
- {language.t("app.server.otherServers")} -
- - {(conn) => { - const key = ServerConnection.key(conn) - return ( - - ) - }} - -
-
-
-
- ) -} - -function ServerKey(props: ParentProps) { - const server = useServer() - return ( - - {props.children} - - ) -} - export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key @@ -454,65 +267,60 @@ export function AppInterface(props: { router?: Component disableHealthCheck?: boolean startup?: Promise - serverScoped?: JSX.Element }) { // The visual layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data // providers beneath it. - const ServerShell = (shellProps: ParentProps) => ( - - {props.children} - {shellProps.children} - + const Root = (rootProps: ParentProps) => ( + + + + + + {props.children} + {rootProps.children} + + + ) return ( - - - - - ( - - - - - {routerProps.children} - - - - - )} - > - - - - - - - ) -} - -function Routes() { - return ( - <> - - - - - - - + + + + {/* Proper Routes */} + + + ( + + + + )} + /> + + + {/* Legacy Routes */} + + + + + + + + + ) } function LegacySessionRedirect() { - const server = useServer() + const server = useServers() const tabs = useTabs() const params = useParams<{ id: string }>() @@ -523,7 +331,7 @@ function LegacySessionRedirect() { legacySessionServer( tabs.store.filter((item) => item.type === "session"), params.id, - server.key, + ServerConnection.key(server.list[0]!), ), params.id, )} diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 6455068bb6e..627f2dd5017 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -8,12 +8,13 @@ import { useFile } from "@/context/file" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { useLayout, type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" +import { ServerConnection } from "@/context/servers" import { useServerSDK } from "@/context/server-sdk" import { useTabs } from "@/context/tabs" import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { useServer } from "@/context/server" export type CommandPaletteEntry = { id: string @@ -83,7 +84,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const language = useLanguage() const file = useFile() const dialog = useDialog() - const serverSDK = useServerSDK()() + const serverSDK = useServerSDK() const serverCtx = global.ensureServerCtx(serverSDK.server) const appTabs = useTabs() const { tabs: sessionTabs } = useSessionLayout() diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index e996fd0be77..2470989f27f 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" +import { ServerConnection } from "@/context/servers" import { useTabs } from "@/context/tabs" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { getRelativeTime } from "@/utils/time" diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 61629f0fbc2..54ff210a90b 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -387,7 +387,7 @@ function ProviderConnection(props: { id: props.provider, name: providers.all().get(props.provider)?.name ?? - serverSync().data.provider.all.get(props.provider)?.name ?? + serverSync.data.provider.all.get(props.provider)?.name ?? controller.integration()?.name ?? props.provider, })) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 998068c68cb..c777321aecd 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const output = validateCustomProvider({ form, t: language.t, - disabledProviders: serverSync().data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync().data.provider.all.keys()), + disabledProviders: serverSync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(serverSync.data.provider.all.keys()), }) batch(() => { setForm("err", output.err) diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx index cfed789c792..6deb3c4c28f 100644 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -10,8 +10,8 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { For, Show, createSignal, startTransition } from "solid-js" import { useLanguage } from "@/context/language" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/servers" import { SDKProvider } from "@/context/sdk" -import { ServerConnection } from "@/context/server" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { createEditProjectModel } from "./edit-project" import { ProjectSettingsExtensions } from "./project-settings-extensions" diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index e3bf4c19af7..d3a57c6e6b7 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" +import { ServerConnection } from "@/context/servers" import type { Path } from "@/types" import { absoluteTreePath, diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 3d843787d7d..29e0c1c9c36 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { createMemo, createResource, createSignal } from "solid-js" import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" +import { ServerConnection } from "@/context/servers" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import type { Path } from "@/types" diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 905abe1e2dd..c389a4e591c 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -9,7 +9,6 @@ import { formatKeybind } from "@/context/command" import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" @@ -29,14 +28,13 @@ type DialogSelectFileMode = "all" | "files" export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { const platform = usePlatform() - const settings = useSettings() const filesOnly = () => props.mode === "files" - if (!filesOnly() && settings.general.newLayoutDesigns()) { + if (!filesOnly()) { return } - if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + if (filesOnly() && platform.platform === "desktop") { return } @@ -52,7 +50,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void return ( void onEdit: (server: ServerConnection.Http) => void }) { const language = useLanguage() - const settings = useSettings() return (
@@ -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)} - > - - - - - - {language.t("session.header.openIn")} - - { - if (!OPEN_APPS.includes(value as OpenApp)) return - selectApp(value as OpenApp) - }} - > - - {(o) => ( - { - setMenu("open", false) - openDir(o.id) - }} - > -
- -
- {o.label} - - - -
- )} -
-
-
- - { - setMenu("open", false) - copyPath() - }} - > -
- -
- - {language.t("session.header.open.copyPath")} - -
-
-
-
-
-
-
-
- -
- - - - - - - - - - -
-
- } - > - - - - )} - - + + {(mount) => ( + + + + )} + ) } @@ -527,8 +57,6 @@ type SessionHeaderV2ActionsState = { } function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { - const language = useLanguage() - return (
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 5e4631addd2..3a504164368 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -1,11 +1,6 @@ -import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" +import { For, Show, createMemo, lazy, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" -import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { TextField } from "@opencode-ai/ui/text-field" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" @@ -14,7 +9,6 @@ import fuzzysort from "fuzzysort" import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { SettingsList } from "./settings-list" import { SettingsListV2 } from "./settings-v2/parts/list" const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) @@ -202,69 +196,6 @@ function filteredFor( return out } -function useKeyCapture(input: { - active: () => string | null - stop: () => void - set: (id: string, keybind: string) => void - used: () => Map - language: ReturnType -}) { - onMount(() => { - const handle = (event: KeyboardEvent) => { - const id = input.active() - if (!id) return - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - - if (event.key === "Escape") { - input.stop() - return - } - - const clear = - (event.key === "Backspace" || event.key === "Delete") && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - if (clear) { - input.set(id, "none") - input.stop() - return - } - - const next = recordKeybind(event) - if (!next) return - - const conflicts = new Map() - for (const sig of signatures(next)) { - for (const item of input.used().get(sig) ?? []) { - if (item.id === id) continue - conflicts.set(item.id, item.title) - } - } - - if (conflicts.size > 0) { - showToast({ - title: input.language.t("settings.shortcuts.conflict.title"), - description: input.language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next, input.language.t), - titles: [...conflicts.values()].join(", "), - }), - }) - return - } - - input.set(id, next) - input.stop() - } - - makeEventListener(document, "keydown", handle, { capture: true }) - }) -} - export function createKeybindSettingsController( input: { command: Pick @@ -411,7 +342,7 @@ export function createKeybindSettingsController( } } -function SettingsKeybindsV2() { +export function SettingsKeybinds() { const command = useCommand() const settings = useSettings() const controller = createKeybindSettingsController({ @@ -532,252 +463,3 @@ function SettingsKeybindsV2View(props: { ) } - -export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { - if (props.v2) return - - const command = useCommand() - const language = useLanguage() - const settings = useSettings() - - const [store, setStore] = createStore({ - active: null as string | null, - filter: "", - }) - - const stop = () => { - if (!store.active) return - setStore("active", null) - command.keybinds(true) - } - - const start = (id: string) => { - if (store.active === id) { - stop() - return - } - - if (store.active) stop() - - setStore("active", id) - command.keybinds(false) - } - - const map = createMemo(() => keybinds(settings.current.keybinds)) - - const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string")) - - const resetAll = () => { - stop() - settings.keybinds.resetAll() - showToast({ - title: language.t("settings.shortcuts.reset.toast.title"), - description: language.t("settings.shortcuts.reset.toast.description"), - }) - } - - const list = createMemo(() => { - language.locale() - return listFor(command, map(), language.t("command.palette")) - }) - - const title = (id: string) => list().get(id)?.title ?? "" - - const grouped = createMemo(() => groupedFor(list())) - - const filtered = createMemo(() => { - return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "") - }) - - const hasResults = createMemo(() => { - for (const group of GROUPS) { - const ids = filtered().get(group) ?? [] - if (ids.length > 0) return true - } - return false - }) - - const used = createMemo(() => { - const map = new Map() - - const add = (key: string, value: { id: string; title: string }) => { - const list = map.get(key) - if (!list) { - map.set(key, [value]) - return - } - list.push(value) - } - - const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND - for (const sig of signatures(palette)) { - add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) }) - } - - const valueFor = (id: string) => { - const custom = settings.keybinds.get(id) - if (typeof custom === "string") return custom - - const live = command.options.find((x) => x.id === id) - if (live?.keybind) return live.keybind - - const meta = command.catalog.find((x) => x.id === id) - return meta?.keybind - } - - for (const id of list().keys()) { - if (id === PALETTE_ID) continue - for (const sig of signatures(valueFor(id))) { - add(sig, { id, title: title(id) }) - } - } - - return map - }) - - const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind) - - useKeyCapture({ - active: () => store.active, - stop, - set: setKeybind, - used, - language, - }) - - onCleanup(() => { - if (store.active) command.keybinds(true) - }) - - const emptyResults = ( - -
- - {language.t("settings.shortcuts.search.empty")} - - - - "{store.filter}" - - -
-
- ) - - const groups = ( -
- - {(group) => ( - 0}> -
-

- {language.t(groupKey[group])} -

- - - {(id) => ( -
- - {title(id)} - - -
- )} -
-
-
-
- )} -
- {emptyResults} -
- ) - - return ( -
-
-
-
-

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

- -
- -
- - setStore("filter", v)} - placeholder={language.t("settings.shortcuts.search.placeholder")} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="flex-1" - /> - - setStore("filter", "")} /> - -
-
-
- {groups} -
- ) -} diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx index 6c4e809d587..8bb3303d6da 100644 --- a/packages/app/src/components/settings-server-picker.tsx +++ b/packages/app/src/components/settings-server-picker.tsx @@ -1,9 +1,8 @@ import { type ParentProps, Show } from "solid-js" import { useGlobal } from "@/context/global" import { ModelsProvider } from "@/context/models" -import { ServerConnection } from "@/context/server" -import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" +import { ServerProvider } from "@/context/server" +import { ServerConnection } from "@/context/servers" export function SettingsServerScope(props: ParentProps<{ directory?: string }>) { const global = useGlobal() @@ -20,10 +19,8 @@ export function SettingsServerScope(props: ParentProps<{ directory?: string }>) export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) { return ( - - - {props.children} - - + + {props.children} + ) } diff --git a/packages/app/src/components/settings-v2/dialog-server-v2.tsx b/packages/app/src/components/settings-v2/dialog-server-v2.tsx index c385062008c..dade65ae089 100644 --- a/packages/app/src/components/settings-v2/dialog-server-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-server-v2.tsx @@ -13,7 +13,7 @@ import { } from "@/components/server/server-management" import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers" import { useTabs } from "@/context/tabs" import { useCheckServerHealth } from "@/utils/server-health" import "./settings-v2.css" @@ -146,7 +146,7 @@ export const DialogServerV2: Component<{ } function createFormController(options: { onSelect?: () => void } = {}) { - const server = useServer() + const server = useServers() const tabs = useTabs() const global = useGlobal() const language = useLanguage() @@ -173,8 +173,7 @@ function createFormController(options: { onSelect?: () => void } = {}) { }) } const allServers = () => { - if (!server.current || server.list.includes(server.current)) return server.list - return [server.current, ...server.list] + return server.list } const editing = createMemo(() => allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl), @@ -182,10 +181,8 @@ function createFormController(options: { onSelect?: () => void } = {}) { const add = (connection: ServerConnection.Http) => server.add(connection) const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) => replaceServerConnection(originalKey, next, { - active: () => server.key, removeTabs: (key) => tabs.removeServer(key), add, - setActive: (key) => server.setActive(key), remove: (key) => server.remove(key), }) 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 3a8bcbe9470..05c12bcf187 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,10 +1,10 @@ -import { Component, createMemo, createSignal, startTransition } from "solid-js" +import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { SettingsGeneralV2 } from "./general" +import { SettingsGeneral } from "./general" import { SettingsAppearanceV2 } from "./appearance" import { SettingsKeybinds } from "../settings-keybinds" import { SettingsNotificationsV2 } from "./notifications" @@ -18,9 +18,8 @@ import { SettingsServerScope } from "../settings-server-picker" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" -import { useServerSync } from "@/context/server-sync" -import { useGlobal } from "@/context/global" -import { ServerConnection } from "@/context/server" +import { useGlobal, useServerCtx } from "@/context/global" +import { ServerConnection, useServers } from "@/context/servers" import "./settings-v2.css" export const DialogSettings: Component<{ @@ -31,22 +30,40 @@ export const DialogSettings: Component<{ const platform = usePlatform() const dialog = useDialog() const layout = useLayout() + const servers = useServers() const tabs = useTabs() - const serverSync = useServerSync() const global = useGlobal() - const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync()) - if (currentServer) global.settings.server.set(ServerConnection.key(currentServer)) const [tab, setTab] = createSignal(props.defaultValue ?? "general") - const directory = createMemo(() => { - const server = global.settings.server.selected() - if (!server || serverSync() !== global.ensureServerCtx(server).sync) return + const server = createMemo(() => { + const route = layout.route() + switch (route.type) { + case "draft": { + const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) + return servers.list.find((item) => ServerConnection.key(item) === draft?.server) + } + case "session": + return servers.list.find((item) => ServerConnection.key(item) === route.server) + case "home": + return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server) + } + }) + const serverCtx = useServerCtx(server) + + createEffect(() => { + const current = server() + if (current) global.settings.server.set(ServerConnection.key(current)) + }) + + const directory = createMemo(() => { + const selected = global.settings.server.selected() + const current = server() + if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return const route = layout.route() - if (route.type === "dir-new-sesssion") return route.dir if (route.type === "draft") { const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) return draft?.type === "draft" ? draft.directory : undefined } - if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory + if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory return undefined }) @@ -127,7 +144,7 @@ export const DialogSettings: Component<{ - + @@ -136,7 +153,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/extensions.tsx b/packages/app/src/components/settings-v2/extensions.tsx index 89d1a4da4c2..f5ce1ea4b60 100644 --- a/packages/app/src/components/settings-v2/extensions.tsx +++ b/packages/app/src/components/settings-v2/extensions.tsx @@ -23,7 +23,7 @@ export const SettingsExtensionsV2: Component = () => { const serverSdk = useServerSDK() const serverSync = useServerSync() const mcps = createMemo(() => { - const configMcp = serverSync().data.config.mcp ?? {} + const configMcp = serverSync.data.config.mcp ?? {} return Object.entries(configMcp).map(([name, config]) => ({ name, enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false, @@ -31,18 +31,16 @@ export const SettingsExtensionsV2: Component = () => { }) const handleMcpToggle = (item: McpRowItem, checked: boolean) => { - const before = serverSync().data.config.mcp ?? {} + const before = serverSync.data.config.mcp ?? {} const config = before[item.name] if (typeof config !== "object" || config === null) return const next = { ...before, [item.name]: { ...config, enabled: checked } } - serverSync().set("config", "mcp", next) - void serverSync() - .updateConfig({ mcp: next }) - .catch(() => serverSync().set("config", "mcp", before)) + serverSync.set("config", "mcp", next) + void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before)) } const plugins = createMemo(() => { - const raw = serverSync().data.config.plugin ?? [] + const raw = serverSync.data.config.plugin ?? [] return raw.map((item) => { const name = typeof item === "string" ? item : item[0] return { name } diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts index d3a3b7a5788..66b43552bdd 100644 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -17,17 +17,24 @@ import { } from "@/context/settings" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" +import { ServerConnection } from "@/context/servers" +import { useGlobal, useServerCtx } from "@/context/global" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" -export function createPermissionScopeController(sessionID: Accessor) { - const permission = usePermission() - const serverSync = useServerSync() +export function createPermissionScopeController( + server: Accessor, + sessionID: Accessor, +) { + const serverCtx = useServerCtx(server) + const permission = () => serverCtx()?.permission + const directory = createMemo(() => { + const s = server() const id = sessionID() - if (!id) return undefined - return serverSync().session.lineage.peek(id)?.session.location.directory + if (!s || !id) return undefined + return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory }) return { @@ -35,21 +42,21 @@ export function createPermissionScopeController(sessionID: Accessor !!directory()), set: (checked: boolean) => { const id = sessionID() const dir = directory() if (!id || !dir) return - if (checked) return permission.enableAutoAccept(id, dir) - permission.disableAutoAccept(id, dir) + if (checked) return permission()?.enableAutoAccept(id, dir) + permission()?.disableAutoAccept(id, dir) }, } } -export function createShellSettingsController() { - const serverSync = useServerSync() +export function createShellSettingsController(server: Accessor) { + const serverCtx = useServerCtx(server) const [shells] = createResource( async () => { // TODO: Dax is considering the V2 shell discovery and config update APIs. @@ -58,7 +65,7 @@ export function createShellSettingsController() { }, { initialValue: [] as ShellOption[] }, ) - const current = createMemo(() => serverSync().data.config.shell ?? "") + const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "") return { shells: () => shells.latest, @@ -66,7 +73,7 @@ export function createShellSettingsController() { select: (value: string) => { if (value === current()) return // TODO: Dax is considering the V2 shell discovery and config update APIs. - // void serverSync().updateConfig({ shell: value }) + // void serverSync.updateConfig({ shell: value }) }, } } diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index df3aa9a7a65..17f602c424d 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -24,6 +24,7 @@ import { type SoundSettingsController, } from "./general-controllers" import "./settings-v2.css" +import { ServerConnection } from "@/context/servers" const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] const fontSettings = { @@ -297,16 +298,20 @@ const LanguageSetting = () => { ) } -export const SettingsGeneralV2: Component<{ +export const SettingsGeneral: Component<{ sessionID?: string + server?: ServerConnection.Any }> = (props) => { const language = useLanguage() const platform = usePlatform() const settings = useSettings() const mobile = createMediaQuery("(max-width: 767px)") const updater = useUpdaterAction() - const permissionScope = createPermissionScopeController(() => props.sessionID) - const shell = createShellSettingsController() + const permissionScope = createPermissionScopeController( + () => props.server, + () => props.sessionID, + ) + const shell = createShellSettingsController(() => props.server) const desktop = createMemo(() => platform.platform === "desktop") const [pinchZoom, { mutate: setPinchZoom }] = createResource( @@ -535,7 +540,6 @@ export const SettingsGeneralV2: Component<{
-
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,