diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 765e960c8172..6c9f03c7b594 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -13,6 +13,17 @@ ## SolidJS - Always prefer `createStore` over multiple `createSignal` calls +- Use effects only to synchronize with external systems; prefer derived state and explicit actions for app state. + +## Architecture + +- Strong ownership should make consumers simple and reduce branching. +- Prefer strict contexts with explicit domain values; do not add implicit or theoretical fallbacks. +- Keep route, server, directory, session, draft, and tab identities explicit. +- Prefer explicit transactions over synchronization between independent state stores. +- Avoid duplicate or near-duplicate logic; consolidate only when the shared concept is real. +- Capture concrete context once at the start of async work. +- Treat reduced code, branching, and compatibility machinery as refactor deliverables. ## Tool Calling diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8e5d445f4c76..2a8d2d51f5aa 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,65 +1,32 @@ import "@/index.css" -import * as Sentry from "@sentry/solid" +import { LegacyRoot } from "@/app/legacy" +import { V2Root } from "@/app/v2" +import { CommandProvider } from "@/context/command" +import { GlobalProvider } from "@/context/global" +import { HighlightsProvider } from "@/context/highlights" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { ServerConnection, ServerProvider } from "@/context/server" +import { SettingsProvider, useSettings } from "@/context/settings" +import { NotificationServiceProvider } from "@/context/notification" +import { PermissionServiceProvider } from "@/context/permission" +import type { ServerContext } from "@/context/server-context" +import { sessionHref } from "@/utils/v2-route" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { ErrorPage } from "@/pages/error" +import { setV2Toast } from "@/utils/toast" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/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 * as Sentry from "@sentry/solid" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import type { BaseRouterProps } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { Effect } from "effect" -import { - type Component, - createEffect, - createMemo, - createResource, - createSignal, - ErrorBoundary, - For, - type JSX, - lazy, - onCleanup, - type ParentProps, - Show, -} from "solid-js" -import { Dynamic } from "solid-js/web" -import { CommandProvider } 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 } 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 { PromptProvider } from "@/context/prompt" -import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" -import { SettingsProvider, useSettings } from "@/context/settings" -import { TerminalProvider } from "@/context/terminal" -import { TabsProvider } from "@/context/tabs" -import DirectoryLayout from "@/pages/directory-layout" -import Layout from "@/pages/layout" -import { ErrorPage } from "./pages/error" -import { useCheckServerHealth } from "./utils/server-health" - -const HomeRoute = lazy(() => import("@/pages/home")) -const Session = lazy(() => import("@/pages/session")) - -const SessionRoute = Object.assign( - () => ( - - - - ), - { preload: Session.preload }, -) +import { type Component, createEffect, ErrorBoundary, type JSX, type ParentProps, untrack } from "solid-js" function UiI18nBridge(props: ParentProps) { const language = useLanguage() @@ -100,6 +67,7 @@ function BodyDesignClass() { if (typeof document === "undefined") return const enabled = settings.general.newLayoutDesigns() + setV2Toast(enabled) document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("text-[13px]", enabled) @@ -109,50 +77,6 @@ function BodyDesignClass() { return null } -function AppShellProviders(props: ParentProps) { - return ( - - - - - - - - - {props.children} - - - - - - - - ) -} - -function SessionProviders(props: ParentProps) { - return ( - - - - {props.children} - - - - ) -} - -function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { - return ( - - {/*}>*/} - {props.appChildren} - {props.children} - {/**/} - - ) -} - export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -185,119 +109,32 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { - 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), - ) - - return ( - - - - } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} - - - ) +function v2NotificationHref(server: ServerContext, _directory: string, sessionID?: string) { + if (!sessionID) return "/" + return sessionHref(server.key, sessionID) } -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 legacyNotificationHref(_server: ServerContext, directory: string, sessionID?: string) { + if (!sessionID) return `/${base64Encode(directory)}` + return `/${base64Encode(directory)}/session/${sessionID}` } -function ServerKey(props: ParentProps) { - const server = useServer() +function Application(props: ParentProps<{ router?: Component; disableHealthCheck?: boolean }>) { + const platform = usePlatform() + const settings = useSettings() + const desktopV2 = platform.platform === "desktop" && untrack(settings.general.newLayoutDesigns) + const href = desktopV2 ? v2NotificationHref : legacyNotificationHref return ( - - {props.children} - + + + {props.children} + {desktopV2 ? ( + + ) : ( + + )} + + ) } @@ -315,32 +152,18 @@ export function AppInterface(props: { canonicalLocalServer={props.canonicalLocalServer} servers={props.servers} > - - - ( - - - - - - {routerProps.children} - - - - - - )} - > - - - } /> - - - - - + + + + + + + {props.children} + + + + + ) } diff --git a/packages/app/src/app/legacy.tsx b/packages/app/src/app/legacy.tsx new file mode 100644 index 000000000000..56a9c0786388 --- /dev/null +++ b/packages/app/src/app/legacy.tsx @@ -0,0 +1,210 @@ +import { ServerProviders, SessionProviders } from "@/app/route-providers" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection, serverName, useServer } from "@/context/server" +import { ServerContextProvider } from "@/context/server-context" +import { TabsProvider } from "@/context/tabs" +import DirectoryLayout from "@/pages/directory-layout" +import Layout from "@/pages/layout" +import { decode64 } from "@/utils/base64" +import { useCheckServerHealth } from "@/utils/server-health" +import { Splash } from "@opencode-ai/ui/logo" +import { type BaseRouterProps, Navigate, Route, Router, useParams } from "@solidjs/router" +import { Effect } from "effect" +import { + type Component, + createMemo, + createResource, + createSignal, + For, + lazy, + onCleanup, + type ParentProps, + Show, +} from "solid-js" +import { Dynamic } from "solid-js/web" + +const Home = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) +const Session = lazy(() => import("@/pages/session")) + +const SessionRoute = Object.assign( + () => ( + + + + ), + { preload: Session.preload }, +) + +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { + 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), + ) + + 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} + + ) +} + +function LegacyShell( + props: ParentProps<{ + directory: () => string | undefined + sessionID: () => string | undefined + }>, +) { + return ( + + {props.children} + + ) +} + +function LegacyProviders(props: ParentProps<{ disableHealthCheck?: boolean }>) { + const params = useParams<{ dir?: string; id?: string }>() + const server = useServer() + const global = useGlobal() + const directory = createMemo(() => (params.dir ? decode64(params.dir) : undefined)) + return ( + + + + {(connection) => ( + global.createServerCtx(connection)}> + params.id} + > + {props.children} + + + )} + + + + ) +} + +export function LegacyRoot(props: { router?: Component; disableHealthCheck?: boolean }) { + return ( + ( + + {routerProps.children} + + )} + > + + + } /> + + + + ) +} diff --git a/packages/app/src/app/route-providers.tsx b/packages/app/src/app/route-providers.tsx new file mode 100644 index 000000000000..2a31a651df5d --- /dev/null +++ b/packages/app/src/app/route-providers.tsx @@ -0,0 +1,38 @@ +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { LayoutProvider } from "@/context/layout" +import { ModelsProvider } from "@/context/models" +import { NotificationProvider } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" +import { PromptProvider } from "@/context/prompt" +import { TerminalProvider } from "@/context/terminal" +import type { Accessor, ParentProps } from "solid-js" + +export function ServerProviders( + props: ParentProps<{ + directory: Accessor + sessionID: Accessor + }>, +) { + return ( + + + + {props.children} + + + + ) +} + +export function SessionProviders(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} diff --git a/packages/app/src/app/v2.tsx b/packages/app/src/app/v2.tsx new file mode 100644 index 000000000000..ab8aca6b5eb9 --- /dev/null +++ b/packages/app/src/app/v2.tsx @@ -0,0 +1,288 @@ +import { ServerProviders, SessionProviders } from "@/app/route-providers" +import { DebugBar } from "@/components/debug-bar" +import { TitlebarV2 } from "@/components/titlebar-v2" +import { DirectoryProvider, type DirectoryState } from "@/context/directory" +import { useGlobal } from "@/context/global" +import { NavigationProvider, type Navigation } from "@/context/navigation" +import { RouteProvider, type AppRoute } from "@/context/route" +import { type ServerContext, ServerContextProvider } from "@/context/server-context" +import { TabsProvider, useTabs } from "@/context/tabs" +import { DirectoryDataProvider } from "@/pages/directory-layout" +import { ErrorPage } from "@/pages/error" +import { ToastRegion } from "@/utils/toast" +import { requireServerKey, rootSession, sessionHref, sessionQuery } from "@/utils/v2-route" +import type { Session as SessionInfo } from "@opencode-ai/sdk/v2/client" +import { Splash } from "@opencode-ai/ui/logo" +import { type BaseRouterProps, Route, Router, useParams, useSearchParams } from "@solidjs/router" +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/solid-query" +import { type Accessor, type Component, createMemo, lazy, Match, onMount, type ParentProps, Show, Switch } from "solid-js" +import { Dynamic } from "solid-js/web" +import { makeEventListener } from "@solid-primitives/event-listener" +import { + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + deepLinkEvent, + drainPendingDeepLinks, +} from "@/pages/layout/deep-links" + +const Home = lazy(() => import("@/pages/home").then((module) => ({ default: module.V2Home }))) +const Session = lazy(() => import("@/pages/session")) + +const SessionRoute = Object.assign( + () => ( + + + + ), + { preload: Session.preload }, +) + +function V2Shell(props: ParentProps) { + return ( +
+ +
{props.children}
+ {import.meta.env.DEV && } + +
+ ) +} + +function HomeRoute() { + const global = useGlobal() + return ( + ({ type: "home" })}> + + undefined} sessionID={() => undefined}> + + + + + + + ) +} + +function DraftRoute() { + const [search] = useSearchParams<{ draftId?: string }>() + const tabs = useTabs() + return ( + }> + + + ) +} + +function ReadyDraftRoute(props: { draftID?: string }) { + const global = useGlobal() + const tabs = useTabs() + const resolved = createMemo(() => { + const draftID = props.draftID + if (!draftID) throw new Error("Draft route requires a draft ID") + const draft = tabs.draft(draftID) + return { draftID, draft, server: global.servers.get(draft.server) } + }) + const route = createMemo(() => { + const current = resolved()! + return { + type: "draft", + draftID: current.draftID, + server: current.draft.server, + directory: current.draft.directory, + } + }) + const navigation = createMemo(() => { + const current = resolved() + return { + session: (sessionID) => sessionHref(current.draft.server, sessionID), + newSession: () => tabs.newDraft({ server: current.draft.server, directory: current.draft.directory }), + openSession: (sessionID) => tabs.openSession(current.draft.server, sessionID), + selectDirectory: (directory) => tabs.updateDraft(current.draftID, { directory }), + created: (session) => { + tabs.promoteDraft(current.draftID, { server: current.draft.server, sessionId: session.id }) + }, + } + }) + + return ( + resolved().server} + directory={() => resolved().draft.directory} + sessionID={() => undefined} + state={() => ({ type: "draft", id: resolved().draftID })} + navigation={navigation} + /> + ) +} + +type ResolvedServer = { id: string; ctx: ServerContext } +type ResolvedSession = { server: ServerContext; session: SessionInfo; tabID: string } + +function SessionRouteResolver() { + const params = useParams<{ serverKey: string; id: string }>() + const global = useGlobal() + const queryClient = useQueryClient() + const resolved = createMemo(() => { + const key = requireServerKey(params.serverKey) + return { id: params.id, ctx: global.servers.get(key) } + }) + const session = useQuery(() => { + const server = resolved() + return { + queryKey: ["v2", "resolved-session", server.ctx.key, server.ctx.instance, server.id] as const, + placeholderData: keepPreviousData, + queryFn: async () => { + const locator = await queryClient.ensureQueryData( + sessionQuery(server.ctx.key, server.ctx.instance, server.ctx.sdk, server.id), + ) + const root = await rootSession(locator.session, (id) => + queryClient + .ensureQueryData(sessionQuery(server.ctx.key, server.ctx.instance, server.ctx.sdk, id)) + .then((result) => result.session), + ) + return { server: server.ctx, session: locator.session, tabID: root.id } + }, + } + }) + return ( + }> + {(error) => } + + } + > + {(current) => ( + <> + + + + + + )} + + ) +} + +function RouteLoading(props: { overlay?: boolean }) { + return ( +
+ +
+ ) +} + +function ResolvedSessionRoute(props: { resolved: Accessor }) { + const tabs = useTabs() + const route = createMemo(() => { + const { server, session } = props.resolved() + return { + type: "session", + server: server.key, + directory: session.directory, + sessionID: session.id, + tabID: props.resolved().tabID, + } + }) + const navigation = createMemo(() => { + const current = props.resolved() + return { + session: (sessionID) => sessionHref(current.server.key, sessionID), + newSession: () => tabs.newDraft({ server: current.server.key, directory: current.session.directory }), + openSession: (sessionID) => tabs.openSession(current.server.key, sessionID), + selectDirectory: (directory) => tabs.newDraft({ server: current.server.key, directory }), + created: (session) => tabs.openSession(current.server.key, session.id), + } + }) + return ( + <> + + {(resolved) => } + + props.resolved().server} + directory={() => props.resolved().session.directory} + sessionID={() => props.resolved().session.id} + state={() => ({ type: "session", id: props.resolved().session.id })} + navigation={navigation} + /> + + ) +} + +function SessionAdmission(props: { server: ServerContext; sessionID: string }) { + const tabs = useTabs() + onMount(() => tabs.admitSession({ server: props.server.key, sessionId: props.sessionID })) + return null +} + +function V2DirectoryRoute(props: { + route: Accessor + server: Accessor + directory: Accessor + sessionID: Accessor + state: Accessor + navigation: Accessor +}) { + return ( + + + + + + + + + + + + + + + + ) +} + +function V2DeepLinks() { + const global = useGlobal() + const tabs = useTabs() + + const open = (urls: string[]) => { + const server = global.servers.first() + if (!server.isLocal) return + collectOpenProjectDeepLinks(urls).forEach((directory) => tabs.newDraft({ server: server.key, directory })) + collectNewSessionDeepLinks(urls).forEach((link) => + tabs.newDraft({ server: server.key, directory: link.directory }, link.prompt), + ) + } + + onMount(() => { + open(drainPendingDeepLinks(window)) + makeEventListener(window, deepLinkEvent, (event) => open((event as CustomEvent<{ urls: string[] }>).detail.urls)) + }) + return null +} + +export function V2Root(props: { router?: Component }) { + return ( + ( + + + {routerProps.children} + + )} + > + + + + + ) +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d477ea273f3..c27c33266748 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -12,21 +12,20 @@ import { showToast } from "@/utils/toast" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useServerSDK, useServerSync } from "@/context/server-context" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -export function DialogConnectProvider(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string; directory?: string }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() - const providers = useProviders() + const providers = useProviders(() => props.directory) const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } @@ -41,7 +40,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!, + () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) const fallback = createMemo(() => [ { @@ -52,16 +51,18 @@ export function DialogConnectProvider(props: { provider: string }) { const [auth] = createResource( () => props.provider, async () => { - const cached = serverSync.data.provider_auth[props.provider] + const sync = serverSync() + const sdk = serverSDK() + const cached = sync.data.provider_auth[props.provider] if (cached) return cached - const res = await serverSDK.client.provider.auth() + const res = await sdk.client.provider.auth() if (!alive.value) return fallback() - serverSync.set("provider_auth", res.data ?? {}) + sync.set("provider_auth", res.data ?? {}) return res.data?.[props.provider] ?? fallback() }, ) - const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback()) + const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -143,6 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) { } async function selectMethod(index: number, inputs?: Record) { + const sdk = serverSDK() if (timer.current !== undefined) { clearTimeout(timer.current) timer.current = undefined @@ -158,7 +160,7 @@ export function DialogConnectProvider(props: { provider: string }) { } dispatch({ type: "auth.pending" }) const start = Date.now() - await serverSDK.client.provider.oauth + await sdk.client.provider.oauth .authorize( { providerID: props.provider, @@ -331,7 +333,8 @@ export function DialogConnectProvider(props: { provider: string }) { }) async function complete() { - await serverSDK.client.global.dispose() + const sdk = serverSDK() + await sdk.client.global.dispose() dialog.close() showToast({ variant: "success", @@ -409,7 +412,8 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - await serverSDK.client.auth.set({ + const sdk = serverSDK() + await sdk.client.auth.set({ providerID: props.provider, auth: { type: "api", @@ -480,7 +484,8 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - const result = await serverSDK.client.provider.oauth + const sdk = serverSDK() + const result = await sdk.client.provider.oauth .callback({ providerID: props.provider, method: store.methodIndex, @@ -533,7 +538,8 @@ export function DialogConnectProvider(props: { provider: string }) { onMount(() => { void (async () => { - const result = await serverSDK.client.provider.oauth + const sdk = serverSDK() + const result = await sdk.client.provider.oauth .callback({ providerID: props.provider, method: store.methodIndex, diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 38690bb24106..8ee0b1d99bce 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -9,14 +9,14 @@ import { showToast } from "@/utils/toast" import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useServerSDK, useServerSync } from "@/context/server-context" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { DialogSelectProvider } from "./dialog-select-provider" type Props = { back?: "providers" | "close" + directory?: string } export function DialogCustomProvider(props: Props) { @@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) { dialog.close() return } - dialog.show(() => ) + dialog.show(() => ) } const addModel = () => { @@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) { 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) @@ -118,11 +118,13 @@ export function DialogCustomProvider(props: Props) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - const disabledProviders = serverSync.data.config.disabled_providers ?? [] + const sync = serverSync() + const sdk = serverSDK() + const disabledProviders = sync.data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) if (result.key) { - await serverSDK.client.auth.set({ + await sdk.client.auth.set({ providerID: result.providerID, auth: { type: "api", @@ -131,7 +133,7 @@ export function DialogCustomProvider(props: Props) { }) } - await serverSync.updateConfig({ + await sync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled, }) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 29ee4dfeabdd..34891ac73ad8 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -76,23 +76,25 @@ export function DialogEditProject(props: { project: LocalProject; server: Server const saveMutation = useMutation(() => ({ mutationFn: async () => { + const sdk = serverSDK() + const sync = serverSync() const name = store.name.trim() === folderName() ? "" : store.name.trim() const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverSDK().client.project.update({ + await sdk.client.project.update({ projectID: props.project.id, directory: props.project.worktree, name, icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) + sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } - serverSync().project.meta(props.project.worktree, { + sync.project.meta(props.project.worktree, { name, icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index d0d105e078e9..aa63152b76a9 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -1,7 +1,5 @@ import { Component, createMemo } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { useDirectory, useSync } from "@/context/directory" import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,8 +7,8 @@ import { List } from "@opencode-ai/ui/list" import { showToast } from "@/utils/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" +import { useNavigation } from "@/context/navigation" interface ForkableMessage { id: string @@ -23,25 +21,24 @@ function formatTime(date: Date): string { } export const DialogFork: Component = () => { - const params = useParams() - const navigate = useNavigate() + const directory = useDirectory() + const navigation = useNavigation() const sync = useSync() - const sdk = useSDK() const prompt = usePrompt() const dialog = useDialog() const language = useLanguage() const messages = createMemo((): ForkableMessage[] => { - const sessionID = params.id + const sessionID = directory().sessionID if (!sessionID) return [] - const msgs = sync.data.message[sessionID] ?? [] + const msgs = sync().data.message[sessionID] ?? [] const result: ForkableMessage[] = [] for (const message of msgs) { if (message.role !== "user") continue - const parts = sync.data.part[message.id] ?? [] + const parts = sync().data.part[message.id] ?? [] const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) if (!textPart) continue @@ -58,17 +55,18 @@ export const DialogFork: Component = () => { const handleSelect = (item: ForkableMessage | undefined) => { if (!item) return - const sessionID = params.id + const sessionID = directory().sessionID if (!sessionID) return - const parts = sync.data.part[item.id] ?? [] + const parts = sync().data.part[item.id] ?? [] + const current = directory() const restored = extractPromptFromParts(parts, { - directory: sdk.directory, + directory: current.sdk.directory, attachmentName: language.t("common.attachment"), }) - const dir = base64Encode(sdk.directory) + const destination = navigation() - sdk.client.session + current.sdk.client.session .fork({ sessionID, messageID: item.id }) .then((forked) => { if (!forked.data) { @@ -76,8 +74,12 @@ export const DialogFork: Component = () => { return } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { + serverScope: current.server.scope, + directory: current.directory, + state: { type: "session", id: forked.data.id }, + }) + destination.openSession(forked.data.id) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad96..79eabd495205 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "./dialog-select-provider" +import { useDirectory } from "@/context/directory" export const DialogManageModels: Component = () => { const local = useLocal() + const directory = useDirectory() const language = useLanguage() const dialog = useDialog() const handleConnectProvider = () => { - dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 5c024ec3b4cb..3834da2ee47a 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -4,19 +4,16 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/core/util/encode" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { useNavigate } from "@solidjs/router" +import { useNavigation } from "@/context/navigation" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useServerSDK, useServerSync } from "@/context/server-context" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" -import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -203,11 +200,12 @@ function createSessionEntries(props: { const current = state.token const dirs = props.workspaces() if (dirs.length === 0) return [] as Entry[] + const serverSDK = props.serverSDK() state.inflight = Promise.all( dirs.map((directory) => { const description = props.label(directory) - return props.serverSDK.client.session + return serverSDK.client.session .list({ directory, roots: true }) .then((x) => (x.data ?? []) @@ -267,17 +265,17 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const layout = useLayout() const file = useFile() const dialog = useDialog() - const navigate = useNavigate() + const navigation = useNavigation() const serverSDK = useServerSDK() const serverSync = useServerSync() - const { params, tabs, view } = useSessionLayout() + const { directory, tabs, view } = useSessionLayout() const filesOnly = () => props.mode === "files" const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) const commandEntries = createCommandEntries({ filesOnly, command, language }) const fileEntries = createFileEntries({ file, tabs, language }) - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const projectDirectory = createMemo(directory) const project = createMemo(() => { const directory = projectDirectory() if (!directory) return @@ -292,14 +290,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (directory && !dirs.includes(directory)) return [...dirs, directory] return dirs }) - const homedir = createMemo(() => serverSync.data.path.home) + const homedir = createMemo(() => serverSync().data.path.home) const label = (directory: string) => { const current = project() const kind = current && directory === current.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const [store] = serverSync.child(directory, { bootstrap: false }) + const [store] = serverSync().child(directory, { bootstrap: false }) const home = homedir() const path = home ? directory.replace(home, "~") : directory const name = store.vcs?.branch ?? getFilename(directory) @@ -369,8 +367,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + if (!item.sessionID) return + navigation().openSession(item.sessionID) return } diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 49d8ee677285..c4052c7d9743 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,12 +1,11 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query" import { Component, createMemo, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { useSDK, useSync } from "@/context/directory" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" -import { useQueryOptions } from "@/context/server-sync" +import { useServerContext } from "@/context/server-context" import { pathKey } from "@/utils/path-key" const statusLabels = { @@ -22,28 +21,30 @@ export const DialogSelectMcp: Component = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() - const queryOptions = useQueryOptions() + const server = useServerContext() + const queryOptions = () => server().sync.queryOptions const items = createMemo(() => - Object.entries(sync.data.mcp ?? {}) + Object.entries(sync().data.mcp ?? {}) .map(([name, status]) => ({ name, status: status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) const toggle = useMutation(() => ({ mutationFn: async (name: string) => { - const status = sync.data.mcp[name] + const current = sdk() + const status = sync().data.mcp[name] if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) + await current.client.mcp.disconnect({ name }) return } if (status?.status === "needs_auth") { - await sdk.client.mcp.auth.authenticate({ name }) + await current.client.mcp.auth.authenticate({ name }) return } - await sdk.client.mcp.connect({ name }) + await current.client.mcp.connect({ name }) }, - onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), + onSuccess: () => queryClient.refetchQueries(queryOptions().mcp(pathKey(sync().directory))), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) @@ -68,7 +69,7 @@ export const DialogSelectMcp: Component = () => { }} > {(i) => { - const mcpStatus = () => sync.data.mcp[i.name] + const mcpStatus = () => sync().data.mcp[i.name] const status = () => mcpStatus()?.status const statusLabel = () => { const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81bd..69bf98e4f926 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,24 +10,26 @@ import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { useDirectory } from "@/context/directory" type ModelState = ReturnType["model"] export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { const model = props.model ?? useLocal().model + const directory = useDirectory() const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(() => directory().directory) const language = useLanguage() const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const all = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5ba..bc1f1d2e22fb 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { useDirectory } from "@/context/directory" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -104,6 +105,7 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const directory = useDirectory() const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -120,7 +122,7 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } const language = useLanguage() @@ -198,11 +200,12 @@ export function ModelSelectorPopover(props: { export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() + const directory = useDirectory() const language = useLanguage() const provider = () => { void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 89310286daf2..dc5489e10938 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" -export const DialogSelectProvider: Component = () => { +export const DialogSelectProvider: Component<{ directory?: string }> = (props) => { const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(() => props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") @@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => { onSelect={(x) => { if (!x) return if (x.id === CUSTOM_ID) { - dialog.show(() => ) + dialog.show(() => ) return } - dialog.show(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 90a644097b3d..1c08418bd2cf 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -9,7 +9,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@/utils/toast" import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, createResource, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, Show } from "solid-js" import { createStore } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { useGlobal } from "@/context/global" @@ -19,6 +19,7 @@ import { normalizeServerUrl, ServerConnection, useServer } from "@/context/serve import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" +import { useServerContext } from "@/context/server-context" const DEFAULT_USERNAME = "opencode" @@ -193,6 +194,7 @@ export function useServerManagementController(options: { onSelect?: () => void } const navigate = useNavigate() const server = useServer() const tabs = useTabs() + const currentServer = useServerContext() const global = useGlobal() const platform = usePlatform() const language = useLanguage() @@ -303,7 +305,7 @@ export function useServerManagementController(options: { onSelect?: () => void } return } if (normalized === input.original.http.url) { - server.add(conn) + global.servers.replace(ServerConnection.key(input.original), conn) } else { replaceServer(input.original, conn) } @@ -314,13 +316,11 @@ export function useServerManagementController(options: { onSelect?: () => void } const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const originalKey = ServerConnection.key(original) - const active = server.key - tabs.removeServer(originalKey) - const newConn = server.add(next) - if (!newConn) return - const nextActive = active === originalKey ? ServerConnection.key(newConn) : active - if (nextActive) server.setActive(nextActive) - server.remove(originalKey) + batch(() => { + if (currentServer().key === originalKey) navigate("/") + tabs.removeServer(originalKey) + global.servers.replace(originalKey, next) + }) } const items = createMemo(() => { @@ -505,8 +505,11 @@ export function useServerManagementController(options: { onSelect?: () => void } }) async function handleRemove(url: ServerConnection.Key) { - tabs.removeServer(url) - server.remove(url) + batch(() => { + if (currentServer().key === url) navigate("/") + tabs.removeServer(url) + global.servers.remove(url) + }) if ((await platform.getDefaultServer?.()) === url) { void platform.setDefaultServer?.(null) } diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 20d71f4bfd44..54cf2c831614 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -10,7 +10,7 @@ import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" import { SettingsServers } from "./settings-servers" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ directory?: string; sessionID?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() @@ -61,7 +61,7 @@ export const DialogSettings: Component = () => { - + @@ -70,10 +70,10 @@ export const DialogSettings: Component = () => { - + - + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8e4913b1f5b0..a808f84f0fd3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -31,10 +31,8 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" -import { useNavigate } from "@solidjs/router" -import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -import { useSync } from "@/context/sync" +import { useSDK, useSync } from "@/context/directory" +import { useServerContext } from "@/context/server-context" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" @@ -74,10 +72,9 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/server-sync" import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" import { displayName } from "@/pages/layout/helpers" +import { useNavigation } from "@/context/navigation" interface PromptInputProps { class?: string @@ -123,24 +120,24 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() + const server = useServerContext() + const queryOptions = () => server().sync.queryOptions const sync = useSync() const local = useLocal() const files = useFile() const prompt = usePrompt() const layout = useLayout() - const server = useServer() + const navigation = useNavigation() const comments = useComments() const dialog = useDialog() - const providers = useProviders() + const providers = useProviders(() => sdk().directory) const command = useCommand() const permission = usePermission() const language = useLanguage() const platform = usePlatform() const settings = useSettings() - const { params, tabs, view } = useSessionLayout() + const { sessionID: currentSessionID, tabs, view } = useSessionLayout() let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement @@ -198,10 +195,10 @@ export const PromptInput: Component = (props) => { }).activeFileTab const commentInReview = (path: string) => { - const sessionID = params.id + const sessionID = currentSessionID() if (!sessionID) return false - const diffs = sync.data.session_diff[sessionID] + const diffs = sync().data.session_diff[sessionID] if (!diffs) return false return diffs.some((diff) => diff.file === path) } @@ -263,8 +260,8 @@ export const PromptInput: Component = (props) => { return paths }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const working = createMemo(() => sync.data.session_working(params.id ?? "")) + const info = createMemo(() => (currentSessionID() ? sync().session.get(currentSessionID()!) : undefined)) + const working = createMemo(() => sync().data.session_working(currentSessionID() ?? "")) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -341,9 +338,9 @@ export const PromptInput: Component = (props) => { }) const hasUserPrompt = createMemo(() => { - const sessionID = params.id + const sessionID = currentSessionID() if (!sessionID) return false - const messages = sync.data.message[sessionID] + const messages = sync().data.message[sessionID] if (!messages) return false return messages.some((m) => m.role === "user") }) @@ -554,8 +551,8 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - params.id - if (params.id) return + currentSessionID() + if (currentSessionID()) return if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) @@ -584,7 +581,7 @@ export const PromptInput: Component = (props) => { } const agentList = createMemo(() => - sync.data.agent + sync().data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) @@ -653,7 +650,7 @@ export const PromptInput: Component = (props) => { type: "builtin" as const, })) - const custom = sync.data.command.map((cmd) => ({ + const custom = sync().data.command.map((cmd) => ({ id: `custom.${cmd.name}`, trigger: cmd.name, title: cmd.name, @@ -1106,9 +1103,9 @@ export const PromptInput: Component = (props) => { // Check provider variants directly: `variants` also includes the UI-only default option. const showVariantControl = createMemo(() => local.model.variant.list().length > 0) const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) + const id = currentSessionID() + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) }) const { abort, handleSubmit } = createPromptSubmit({ @@ -1299,9 +1296,9 @@ export const PromptInput: Component = (props) => { const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ queries: [ - queryOptions.agents(pathKey(sdk.directory)), - queryOptions.providers(null), - queryOptions.providers(pathKey(sdk.directory)), + queryOptions().agents(pathKey(sdk().directory)), + queryOptions().providers(null), + queryOptions().providers(pathKey(sdk().directory)), ], })) @@ -1346,7 +1343,7 @@ export const PromptInput: Component = (props) => { (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) } - const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) + const selectedProject = createMemo(() => projectForDirectory(sdk().directory)) const projectResults = createMemo(() => { const search = picker.projectSearch.trim().toLowerCase() if (!search) return projects() @@ -1363,18 +1360,17 @@ export const PromptInput: Component = (props) => { return } layout.projects.open(worktree) - server.projects.touch(worktree) - navigate(`/${base64Encode(worktree)}/session`) + server().projects.touch(worktree) + navigation().selectDirectory(worktree) } const addProject = async () => { - const conn = server.current - if (!conn) return + const conn = server().connection const select = (result: string | string[] | null) => { const directory = Array.isArray(result) ? result[0] : result if (!directory) return selectProject(directory) } - if (platform.openDirectoryPickerDialog && server.isLocal()) { + if (platform.openDirectoryPickerDialog && server().isLocal) { select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") })) return } diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 092731a9ea7a..b6b9709cd393 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -61,6 +61,7 @@ beforeAll(async () => { mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, useParams: () => params, + useSearchParams: () => [{}], })) mock.module("@opencode-ai/sdk/v2/client", () => ({ @@ -124,7 +125,8 @@ beforeAll(async () => { }), })) - mock.module("@/context/sdk", () => ({ + mock.module("@/context/directory", () => ({ + useDirectory: () => () => ({ sessionID: params.id }), useSDK: () => { const sdk = { scope: "local", @@ -135,11 +137,8 @@ beforeAll(async () => { return clientFor(opts.directory) }, } - return sdk + return () => sdk }, - })) - - mock.module("@/context/sync", () => ({ useSync: () => ({ data: { command: [] }, session: { @@ -163,7 +162,7 @@ beforeAll(async () => { }), })) - mock.module("@/context/server-sync", () => ({ + mock.module("@/context/server-context", () => ({ useServerSync: () => ({ child: (directory: string) => { syncedDirectories.push(directory) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 024bdd7ae2e6..d767df05a3a2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,24 +1,30 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" -import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" -import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" -import { useServerSync } from "@/context/server-sync" +import type { ServerSync } from "@/context/server-sync" +import { useServerSync } from "@/context/server-context" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { + DirectoryState, + useDirectory, + useSDK, + useSync, + type DirectorySDK, + type DirectorySync, +} from "@/context/directory" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" +import { useNavigation } from "@/context/navigation" type PendingPrompt = { abort: AbortController @@ -38,9 +44,9 @@ export type FollowupDraft = { } type FollowupSendInput = { - client: ReturnType["client"] - serverSync: ReturnType - sync: ReturnType + client: DirectorySDK["client"] + serverSync: ReturnType> + sync: DirectorySync draft: FollowupDraft messageID?: string optimisticBusy?: boolean @@ -202,8 +208,11 @@ type CommentItem = { preview?: string } +type PromptBinding = ReturnType["bind"]> + export function createPromptSubmit(input: PromptSubmitInput) { - const navigate = useNavigate() + const navigation = useNavigation() + const directory = useDirectory() const sdk = useSDK() const sync = useSync() const serverSync = useServerSync() @@ -212,8 +221,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const prompt = usePrompt() const layout = useLayout() const language = useLanguage() - const params = useParams() - const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID) + const pendingKey = (scope: DirectorySDK["scope"], sessionID: string) => ScopedKey.from(scope, sessionID) const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -225,16 +233,18 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const abort = async () => { - const sessionID = params.id + const sessionID = directory().sessionID if (!sessionID) return Promise.resolve() + const current = sdk() + const currentServerSync = serverSync() - serverSync.todo.set(sessionID, []) - const [, setStore] = serverSync.child(sdk.directory) + currentServerSync.todo.set(sessionID, []) + const [, setStore] = currentServerSync.child(current.directory) setStore("todo", sessionID, []) input.onAbort?.() - const key = pendingKey(sessionID) + const key = pendingKey(current.scope, sessionID) const queued = pending.get(key) if (queued) { queued.abort.abort() @@ -242,16 +252,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { pending.delete(key) return Promise.resolve() } - return sdk.client.session + return current.client.session .abort({ sessionID, }) .catch(() => {}) } - const restoreCommentItems = (items: CommentItem[]) => { + const restoreCommentItems = (current: PromptBinding, items: CommentItem[]) => { for (const item of items) { - prompt.context.add({ + current.context.add({ type: "file", path: item.path, selection: item.selection, @@ -263,19 +273,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const removeCommentItems = (items: { key: string }[]) => { + const removeCommentItems = (current: PromptBinding, items: { key: string }[]) => { for (const item of items) { - prompt.context.remove(item.key) + current.context.remove(item.key) } } - const clearContext = () => { - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) + const clearContext = (current: PromptBinding) => { + for (const item of current.context.items()) { + current.context.remove(item.key) } } - const seed = (dir: string, info: Session) => { + const seed = (serverSync: ServerSync, dir: string, info: Session) => { const [, setStore] = serverSync.child(dir) setStore("session", (list: Session[]) => { const result = Binary.search(list, info.id, (item) => item.id) @@ -291,8 +301,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() - - const currentPrompt = prompt.current() + const destination = navigation() + const currentDirectory = directory() + const sourceKey = DirectoryState.key({ + serverScope: currentDirectory.server.scope, + directory: currentDirectory.directory, + state: currentDirectory.state, + }) + const current = sdk() + const currentSync = sync() + const currentServerSync = serverSync() + const currentPromptState = prompt.bind() + let promptState = currentPromptState + const currentLocalSession = local.session.bind() + const currentPermission = permission.bind() + + const currentPrompt = currentPromptState.current() + const currentContext = currentPromptState.context.items().slice() const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() @@ -316,13 +341,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.addToHistory(currentPrompt, mode) input.resetHistoryNavigation() - const projectDirectory = sdk.directory - const isNewSession = !params.id + const projectDirectory = current.directory + const isNewSession = !directory().sessionID const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory - let client = sdk.client + let client = current.client if (isNewSession) { if (worktreeSelection === "create") { @@ -344,7 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) return } - WorktreeState.pending(sdk.scope, createdWorktree.directory) + WorktreeState.pending(current.scope, createdWorktree.directory) sessionDirectory = createdWorktree.directory } @@ -353,11 +378,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (sessionDirectory !== projectDirectory) { - client = sdk.createClient({ + client = current.createClient({ directory: sessionDirectory, throwOnError: true, }) - serverSync.child(sessionDirectory) + currentServerSync.child(sessionDirectory) } input.onNewSessionWorktreeReset?.() @@ -376,12 +401,21 @@ export function createPromptSubmit(input: PromptSubmitInput) { return undefined }) if (created) { - seed(sessionDirectory, created) + seed(currentServerSync, sessionDirectory, created) session = created - if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + if (shouldAutoAccept) currentPermission.enableAutoAccept(session.id, sessionDirectory) + currentLocalSession.promote(sessionDirectory, session.id) + layout.promoteTabs( + { serverScope: current.scope, directory: currentDirectory.directory, state: currentDirectory.state }, + { serverScope: current.scope, directory: sessionDirectory, state: { type: "session", id: session.id } }, + ) + promptState = prompt.bind({ + serverScope: current.scope, + directory: sessionDirectory, + state: { type: "session", id: session.id }, + }) + currentPromptState.reset() + destination.created({ id: session.id, directory: sessionDirectory }) } } if (!session) { @@ -397,7 +431,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { providerID: currentModel.provider.id, } const agent = currentAgent.name - const context = prompt.context.items().slice() + const context = currentContext const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, @@ -409,13 +443,20 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const clearInput = () => { - prompt.reset() + promptState.reset() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { - prompt.set(currentPrompt, input.promptLength(currentPrompt)) + promptState.set(currentPrompt, input.promptLength(currentPrompt)) + const selected = directory() + const selectedKey = DirectoryState.key({ + serverScope: selected.server.scope, + directory: selected.directory, + state: selected.state, + }) + if (selectedKey !== sourceKey) return input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { @@ -429,7 +470,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { input.onQueue?.(draft) - clearContext() + clearContext(promptState) clearInput() return } @@ -458,7 +499,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) - const customCommand = sync.data.command.find((c) => c.name === commandName) + const customCommand = currentSync.data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() client.session @@ -492,35 +533,35 @@ export function createPromptSubmit(input: PromptSubmitInput) { const messageID = Identifier.ascending("message") const removeOptimisticMessage = () => { - sync.session.optimistic.remove({ + currentSync.session.optimistic.remove({ directory: sessionDirectory, sessionID: session.id, messageID, }) } - removeCommentItems(commentItems) + removeCommentItems(promptState, commentItems) clearInput() const waitForWorktree = async () => { - const worktree = WorktreeState.get(sdk.scope, sessionDirectory) + const worktree = WorktreeState.get(current.scope, sessionDirectory) if (!worktree || worktree.status !== "pending") return true if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) + currentSync.set("session_status", session.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + currentSync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() - restoreCommentItems(commentItems) + restoreCommentItems(promptState, commentItems) restoreInput() } - pending.set(pendingKey(session.id), { abort: controller, cleanup }) + pending.set(pendingKey(current.scope, session.id), { abort: controller, cleanup }) const abortWait = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -547,13 +588,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { }, timeoutMs) }) - const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally( + const result = await Promise.race([WorktreeState.wait(current.scope, sessionDirectory), abortWait, timeout]).finally( () => { if (timer.id === undefined) return clearTimeout(timer.id) }, ) - pending.delete(pendingKey(session.id)) + pending.delete(pendingKey(current.scope, session.id)) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -561,23 +602,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { void sendFollowupDraft({ client, - sync, - serverSync, + sync: currentSync, + serverSync: currentServerSync, draft, messageID, optimisticBusy: sessionDirectory === projectDirectory, before: waitForWorktree, }).catch((err) => { - pending.delete(pendingKey(session.id)) + pending.delete(pendingKey(current.scope, session.id)) if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + currentSync.set("session_status", session.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), description: errorMessage(err), }) removeOptimisticMessage() - restoreCommentItems(commentItems) + restoreCommentItems(promptState, commentItems) restoreInput() }) } diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 1f65e9adb3c7..f1e650f701ff 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -5,7 +5,7 @@ import { Button } from "@opencode-ai/ui/button" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" -import { useSync } from "@/context/sync" +import { useDirectory, useSync } from "@/context/directory" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" @@ -33,8 +33,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const file = useFile() const layout = useLayout() const language = useLanguage() - const providers = useProviders() - const { params, tabs, view } = useSessionLayout() + const directory = useDirectory() + const providers = useProviders(() => directory().directory) + const { sessionID, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") const tabState = createSessionTabs({ @@ -42,7 +43,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) const usd = createMemo( () => @@ -59,7 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }) const openContext = () => { - if (!params.id) return + if (!sessionID()) return if (tabState.activeTab() === "context") { tabs().close("context") @@ -102,7 +103,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) return ( - + {circle()} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 88c3889858c8..c1bcd68fc0f7 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,6 +1,6 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" +import { useSync } from "@/context/directory" import { checksum } from "@opencode-ai/core/util/encode" import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" @@ -17,6 +17,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" +import { useDirectory } from "@/context/directory" const BREAKDOWN_COLOR: Record = { system: "var(--syntax-info)", @@ -92,17 +93,18 @@ const emptyUserMessages: UserMessage[] = [] export function SessionContextTab() { const sync = useSync() + const directory = useDirectory() const language = useLanguage() - const providers = useProviders() - const { params, view } = useSessionLayout() + const providers = useProviders(() => directory().directory) + const { sessionID, view } = useSessionLayout() - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = createMemo(() => (sessionID() ? sync().session.get(sessionID()!) : undefined)) const messages = createMemo( () => { - const id = params.id + const id = sessionID() if (!id) return emptyMessages - return (sync.data.message[id] ?? []) as Message[] + return (sync().data.message[id] ?? []) as Message[] }, emptyMessages, { equals: same }, @@ -180,7 +182,7 @@ export function SessionContextTab() { if (!c?.input) return [] return estimateSessionContextBreakdown({ messages: messages(), - parts: sync.data.part as Record, + parts: sync().data.part as Record, input: c.input, systemPrompt: systemPrompt(), }) @@ -197,7 +199,7 @@ export function SessionContextTab() { } const stats = [ - { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, + { label: "context.stats.session", value: () => info()?.title ?? sessionID() ?? "—" }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, @@ -221,7 +223,7 @@ export function SessionContextTab() { let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined - const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[] + const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] const restoreScroll = () => { const el = scroll diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b983633d8069..b0d6650d6bc7 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -15,18 +15,18 @@ 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 { useServerContext } from "@/context/server-context" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" +import { useSync } from "@/context/directory" 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 { Persist, persisted } from "@/utils/persist" import { StatusPopover, 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 { TITLEBAR_PORTAL_ID } from "../titlebar-shared" const OPEN_APPS = [ "vscode", @@ -134,15 +134,15 @@ const showRequestError = (language: ReturnType, err: unknown export function SessionHeader() { const layout = useLayout() const command = useCommand() - const server = useServer() + const server = useServerContext() const platform = usePlatform() const language = useLanguage() const settings = useSettings() const sync = useSync() const terminal = useTerminal() - const { params, view } = useSessionLayout() + const { directory, sessionID, view } = useSessionLayout() - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const projectDirectory = createMemo(directory) const project = createMemo(() => { const directory = projectDirectory() if (!directory) return @@ -222,7 +222,7 @@ export function SessionHeader() { 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) ?? @@ -231,7 +231,7 @@ export function SessionHeader() { ) const opening = createMemo(() => openRequest.app !== undefined) const tint = createMemo(() => - messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), + messageAgentColor(sessionID() ? sync().data.message[sessionID()!] : undefined, sync().data.agent), ) const v2ActionsState = createMemo(() => ({ statusVisible: status(), @@ -282,8 +282,8 @@ export function SessionHeader() { const [centerMount, setCenterMount] = createSignal(null) const [rightMount, setRightMount] = createSignal(null) onMount(() => { - setCenterMount(document.getElementById("opencode-titlebar-center")) - setRightMount(document.getElementById("opencode-titlebar-right")) + setCenterMount(document.getElementById(TITLEBAR_PORTAL_ID.center)) + setRightMount(document.getElementById(TITLEBAR_PORTAL_ID.right)) }) return ( diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 36c1eb42c316..476651840567 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -1,7 +1,6 @@ import { Show, createMemo } from "solid-js" import { DateTime } from "luxon" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { useSDK, useSync } from "@/context/directory" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" @@ -20,24 +19,24 @@ export function NewSessionView(props: NewSessionViewProps) { const sdk = useSDK() const language = useLanguage() - const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) + const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) const current = createMemo(() => { const selection = props.worktree if (options().includes(selection)) return selection return MAIN_WORKTREE }) - const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) const isWorktree = createMemo(() => { - const project = sync.project + const project = sync().project if (!project) return false - return sdk.directory !== project.worktree + return sdk().directory !== project.worktree }) const label = (value: string) => { if (value === MAIN_WORKTREE) { if (isWorktree()) return language.t("session.new.worktree.main") - const branch = sync.data.vcs?.branch + const branch = sync().data.vcs?.branch if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) return language.t("session.new.worktree.main") } @@ -69,7 +68,7 @@ export function NewSessionView(props: NewSessionViewProps) { {label(current())} - + {(project) => (
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b5e8ffa721d5..115aaab4b777 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -7,14 +7,11 @@ import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { showToast } from "@/utils/toast" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { usePlatform, type DisplayBackend } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-sdk" +import { useServerSDK, useServerSync } from "@/context/server-context" import { monoDefault, monoFontFamily, @@ -27,16 +24,10 @@ import { terminalInput, useSettings, } from "@/context/settings" -import { decode64 } from "@/utils/base64" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" - -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} +import { playDemoSound, stopDemoSound } from "./settings-sound" type ThemeOption = { id: string @@ -55,40 +46,11 @@ type ShellSelectOption = { label: string } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined -} - -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) -} - -export const SettingsGeneral: Component = () => { +export const SettingsGeneral: Component<{ directory?: string; sessionID?: string }> = (props) => { const theme = useTheme() const language = useLanguage() const permission = usePermission() const platform = usePlatform() - const dialog = useDialog() - const params = useParams() const settings = useSettings() const [store, setStore] = createStore({ @@ -96,30 +58,29 @@ export const SettingsGeneral: Component = () => { }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - const dir = createMemo(() => decode64(params.dir)) const accepting = createMemo(() => { - const value = dir() + const value = props.directory if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) + if (!props.sessionID) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(props.sessionID, value) }) const toggleAccept = (checked: boolean) => { - const value = dir() + const value = props.directory if (!value) return - if (!params.id) { + if (!props.sessionID) { if (permission.isAutoAcceptingDirectory(value) === checked) return permission.toggleAutoAcceptDirectory(value) return } if (checked) { - permission.enableAutoAccept(params.id, value) + permission.enableAutoAccept(props.sessionID, value) return } - permission.disableAutoAccept(params.id, value) + permission.disableAutoAccept(props.sessionID, value) } const desktop = createMemo(() => platform.platform === "desktop") @@ -182,7 +143,7 @@ export const SettingsGeneral: Component = () => { const [shells] = createResource( () => - serverSdk.client.pty + serverSdk().client.pty .shells() .then((res) => res.data ?? []) .catch(() => [] as ShellOption[]), @@ -206,11 +167,11 @@ export const SettingsGeneral: Component = () => { }) const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync.data.config.shell ?? "") + const currentShell = createMemo(() => serverSync().data.config.shell ?? "") const shellOptions = createMemo(() => { const list = shells.latest - const current = serverSync.data.config.shell + const current = serverSync().data.config.shell const nameCounts = new Map() for (const s of list) { @@ -328,7 +289,7 @@ export const SettingsGeneral: Component = () => { description={language.t("toast.permissions.autoaccept.on.description")} >
- +
@@ -345,7 +306,7 @@ export const SettingsGeneral: Component = () => { onSelect={(option) => { if (!option) return if (option.value === currentShell()) return - serverSync.updateConfig({ shell: option.value }) + serverSync().updateConfig({ shell: option.value }) }} variant="secondary" size="small" @@ -409,13 +370,7 @@ export const SettingsGeneral: Component = () => {
{ - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - dialog.show(() => ) - }) - }} + onChange={settings.general.setNewLayoutDesigns} />
diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index f3d9e1522ff4..5b2d7740f424 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -32,9 +32,9 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) = ) } -export const SettingsModels: Component = () => { +export const SettingsModels: Component<{ directory?: string }> = (props) => { return ( - + props.directory}> ) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index ef8d03aedd0a..c356054d2267 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -6,8 +6,7 @@ import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useServerSDK, useServerSync } from "@/context/server-context" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" @@ -28,20 +27,20 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, ] as const -export const SettingsProviders: Component = () => { +export const SettingsProviders: Component<{ directory?: string }> = (props) => { return ( - - + props.directory}> + ) } -const SettingsProvidersContent: Component = () => { +const SettingsProvidersContent: Component<{ directory?: string }> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() const serverSync = useServerSync() - const providers = useProviders() + const providers = useProviders(() => props.directory) const connected = createMemo(() => { return providers @@ -83,7 +82,7 @@ const SettingsProvidersContent: 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 @@ -91,11 +90,12 @@ const SettingsProvidersContent: Component = () => { } const disableProvider = async (providerID: string, name: string) => { - const before = serverSync.data.config.disabled_providers ?? [] + const sync = serverSync() + const before = sync.data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] - serverSync.set("config", "disabled_providers", next) + sync.set("config", "disabled_providers", next) - await serverSync + await sync .updateConfig({ disabled_providers: next }) .then(() => { showToast({ @@ -106,22 +106,23 @@ const SettingsProvidersContent: Component = () => { }) }) .catch((err: unknown) => { - serverSync.set("config", "disabled_providers", before) + sync.set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) } const disconnect = async (providerID: string, name: string) => { + const sdk = serverSDK() if (isConfigCustom(providerID)) { - await serverSDK.client.auth.remove({ providerID }).catch(() => undefined) + await sdk.client.auth.remove({ providerID }).catch(() => undefined) await disableProvider(providerID, name) return } - await serverSDK.client.auth + await sdk.client.auth .remove({ providerID }) .then(async () => { - await serverSDK.client.global.dispose() + await sdk.client.global.dispose() showToast({ variant: "success", icon: "circle-check", @@ -209,7 +210,7 @@ const SettingsProvidersContent: Component = () => { variant="secondary" icon="plus-small" onClick={() => { - dialog.show(() => ) + dialog.show(() => ) }} > {language.t("common.connect")} @@ -237,7 +238,7 @@ const SettingsProvidersContent: Component = () => { variant="secondary" icon="plus-small" onClick={() => { - dialog.show(() => ) + dialog.show(() => ) }} > {language.t("common.connect")} @@ -249,7 +250,7 @@ const SettingsProvidersContent: Component = () => { variant="ghost" class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent" onClick={() => { - dialog.show(() => ) + dialog.show(() => ) }} > {language.t("dialog.provider.viewAll")} diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx index 3f679753f053..4bf15211834d 100644 --- a/packages/app/src/components/settings-server-picker.tsx +++ b/packages/app/src/components/settings-server-picker.tsx @@ -1,41 +1,41 @@ import { Button } from "@opencode-ai/ui/button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" -import { QueryClientProvider } from "@tanstack/solid-query" -import { createMemo, For, type ParentProps, Show } from "solid-js" +import { createMemo, For, type Accessor, type ParentProps, Show } from "solid-js" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ModelsProvider } from "@/context/models" import { ServerConnection } from "@/context/server" -import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" +import { ServerContextProvider } from "@/context/server-context" import { useGlobal } from "@/context/global" import { useSettings } from "@/context/settings" -export function SettingsServerScope(props: ParentProps) { +export function SettingsServerScope(props: ParentProps<{ directory: Accessor }>) { const global = useGlobal() const settings = useSettings() return ( - {(server) => {props.children}} + {(server) => ( + + {props.children} + + )} ) } -function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) { +function SettingsServerDataProviders( + props: ParentProps<{ server: ServerConnection.Any; directory: Accessor }>, +) { const global = useGlobal() const serverCtx = () => global.createServerCtx(props.server) return ( - - - - {props.children} - - - + + {props.children} + ) } diff --git a/packages/app/src/components/settings-sound.ts b/packages/app/src/components/settings-sound.ts new file mode 100644 index 000000000000..8dacaf5b5881 --- /dev/null +++ b/packages/app/src/components/settings-sound.ts @@ -0,0 +1,30 @@ +import { playSoundById } from "@/utils/sound" + +const state = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +export function stopDemoSound() { + state.run += 1 + state.cleanup?.() + clearTimeout(state.timeout) + state.cleanup = undefined +} + +export function playDemoSound(id: string | undefined) { + stopDemoSound() + if (!id) return + + const run = ++state.run + state.timeout = setTimeout(() => { + void playSoundById(id).then((cleanup) => { + if (state.run !== run) { + cleanup?.() + return + } + state.cleanup = cleanup + }) + }, 100) +} 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 932f2dedd468..abc9f3ae94d2 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,8 +10,9 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServers } from "../settings-servers" +import { SettingsServerScope } from "../settings-server-picker" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ directory?: string; sessionID?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() @@ -62,7 +63,7 @@ export const DialogSettings: Component = () => {
- + @@ -71,10 +72,14 @@ export const DialogSettings: Component = () => { - + props.directory}> + + - + props.directory}> + + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 9bc5141ba602..eb005829d24f 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -7,14 +7,11 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { showToast } from "@/utils/toast" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { usePlatform, type DisplayBackend } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-sdk" +import { useServerSDK, useServerSync } from "@/context/server-context" import { monoDefault, monoFontFamily, @@ -27,19 +24,13 @@ import { terminalInput, useSettings, } from "@/context/settings" -import { decode64 } from "@/utils/base64" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { SOUND_OPTIONS } from "@/utils/sound" import { Link } from "../link" +import { playDemoSound, stopDemoSound } from "../settings-sound" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} - type ThemeOption = { id: string name: string @@ -57,40 +48,11 @@ type ShellSelectOption = { label: string } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined -} - -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) -} - -export const SettingsGeneralV2: Component = () => { +export const SettingsGeneralV2: Component<{ directory?: string; sessionID?: string }> = (props) => { const theme = useTheme() const language = useLanguage() const permission = usePermission() const platform = usePlatform() - const dialog = useDialog() - const params = useParams() const settings = useSettings() const [store, setStore] = createStore({ @@ -98,30 +60,29 @@ export const SettingsGeneralV2: Component = () => { }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - const dir = createMemo(() => decode64(params.dir)) const accepting = createMemo(() => { - const value = dir() + const value = props.directory if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) + if (!props.sessionID) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(props.sessionID, value) }) const toggleAccept = (checked: boolean) => { - const value = dir() + const value = props.directory if (!value) return - if (!params.id) { + if (!props.sessionID) { if (permission.isAutoAcceptingDirectory(value) === checked) return permission.toggleAutoAcceptDirectory(value) return } if (checked) { - permission.enableAutoAccept(params.id, value) + permission.enableAutoAccept(props.sessionID, value) return } - permission.disableAutoAccept(params.id, value) + permission.disableAutoAccept(props.sessionID, value) } const desktop = createMemo(() => platform.platform === "desktop") @@ -184,7 +145,7 @@ export const SettingsGeneralV2: Component = () => { const [shells] = createResource( () => - serverSdk.client.pty + serverSdk().client.pty .shells() .then((res) => res.data ?? []) .catch(() => [] as ShellOption[]), @@ -208,11 +169,11 @@ export const SettingsGeneralV2: Component = () => { }) const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync.data.config.shell ?? "") + const currentShell = createMemo(() => serverSync().data.config.shell ?? "") const shellOptions = createMemo(() => { const list = shells.latest - const current = serverSync.data.config.shell + const current = serverSync().data.config.shell const nameCounts = new Map() for (const s of list) { @@ -327,7 +288,7 @@ export const SettingsGeneralV2: Component = () => { description={language.t("toast.permissions.autoaccept.on.description")} >
- +
@@ -347,7 +308,7 @@ export const SettingsGeneralV2: Component = () => { onSelect={(option) => { if (!option) return if (option.value === currentShell()) return - serverSync.updateConfig({ shell: option.value }) + serverSync().updateConfig({ shell: option.value }) }} /> @@ -407,13 +368,7 @@ export const SettingsGeneralV2: Component = () => {
{ - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - dialog.show(() => ) - }) - }} + onChange={settings.general.setNewLayoutDesigns} />
diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index a3f058670e4a..3f5c08419650 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -11,6 +11,7 @@ import { popularProviders } from "@/hooks/use-providers" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" +import { SettingsServerPicker } from "../settings-server-picker" type ModelItem = ReturnType["list"]>[number] @@ -45,7 +46,10 @@ export const SettingsModelsV2: Component = () => { return ( <>
-

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

+
+

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

+ +