diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index b6cdbb46..a2bee4b9 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -15,7 +15,6 @@ import { useLocation, useNavigate } from "@solidjs/router"; import type { Agent, Part, - ProviderAuthAuthorization, Session, TextPartInput, FilePartInput, @@ -41,7 +40,7 @@ import ReloadWorkspaceToast from "./components/reload-workspace-toast"; import StatusToast from "./components/status-toast"; import DashboardView from "./pages/dashboard"; import SessionView from "./pages/session"; -import { createClient, unwrap, waitForHealthy, type OpencodeAuth } from "./lib/opencode"; +import { createClient, unwrap } from "./lib/opencode"; import { createDenClient, writeDenSettings } from "./lib/den"; import { abortSession as abortSessionTyped, @@ -71,12 +70,7 @@ import { usesChromeDevtoolsAutoConnect, validateMcpServerName, } from "./mcp"; -import { - compareProviders, - filterProviderList, - mapConfigProvidersToList, - providerPriorityRank, -} from "./utils/providers"; +import { compareProviders, providerPriorityRank } from "./utils/providers"; import { blueprintMaterializedSessions, blueprintSessions, @@ -96,10 +90,7 @@ import type { PluginScope, ReloadReason, ReloadTrigger, - ResetOpenworkMode, SettingsTab, - SkillCard, - SidebarSessionItem, TodoItem, View, WorkspaceSessionGroup, @@ -111,26 +102,22 @@ import type { ComposerPart, ProviderListItem, SessionErrorTurn, - UpdateHandle, OpencodeConnectStatus, - ScheduledJob, WorkspacePreset, } from "./types"; import { clearStartupPreference, deriveArtifacts, deriveWorkingFiles, - formatBytes, formatModelLabel, formatModelRef, - formatRelativeTime, isVisibleTextPart, isTauriRuntime, modelEquals, normalizeDirectoryQueryPath, normalizeDirectoryPath, } from "./utils"; -import { currentLocale, setLocale, t, type Language } from "../i18n"; +import { currentLocale, setLocale, t } from "../i18n"; import { isWindowsPlatform, lastUserModelFromMessages, @@ -150,6 +137,7 @@ import { import { createSystemState } from "./system-state"; import { relaunch } from "@tauri-apps/plugin-process"; import { createSessionStore } from "./context/session"; +import { createProvidersStore } from "./context/providers"; import { formatGenericBehaviorLabel, getModelBehaviorSummary, @@ -158,7 +146,6 @@ import { } from "./lib/model-behavior"; import { describeDirectoryScope, - shouldApplyScopedSessionLoad, shouldRedirectMissingSessionAfterScopedLoad, toSessionTransportDirectory, } from "./lib/session-scope"; @@ -238,7 +225,6 @@ import { type DenAuthDeepLink, type RemoteWorkspaceDefaults, type SharedBundleDeepLink, - type SharedBundleImportIntent, type SharedBundleV1, type SharedSkillBundleV1, type SharedWorkspaceProfileBundleV1, @@ -311,16 +297,6 @@ export default function App() { // ignore } }; - type ProviderAuthMethod = { - type: "oauth" | "api"; - label: string; - methodIndex?: number; - }; - type ProviderOAuthStartResult = { - methodIndex: number; - authorization: ProviderAuthAuthorization; - }; - const location = useLocation(); const navigate = useNavigate(); @@ -831,7 +807,6 @@ export default function App() { const [error, setError] = createSignal(null); const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal(null); const [booting, setBooting] = createSignal(true); - const mountTime = Date.now(); const [lastKnownConfigSnapshot, setLastKnownConfigSnapshot] = createSignal(""); const [developerMode, setDeveloperMode] = createSignal(false); const [documentVisible, setDocumentVisible] = createSignal(true); @@ -891,13 +866,6 @@ export default function App() { type PromptFocusReturnTarget = "none" | "composer"; const [sessionAgentById, setSessionAgentById] = createSignal>({}); - const [providerAuthModalOpen, setProviderAuthModalOpen] = createSignal(false); - const [providerAuthBusy, setProviderAuthBusy] = createSignal(false); - const [providerAuthError, setProviderAuthError] = createSignal(null); - const [providerAuthMethods, setProviderAuthMethods] = createSignal>({}); - const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal(null); - const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] = - createSignal("none"); createEffect(() => { const view = currentView(); @@ -1025,21 +993,11 @@ export default function App() { }); const activeSessions = createMemo(() => sessions()); const activeSessionStatusById = createMemo(() => sessionStatusById()); - const activeMessages = createMemo(() => messages()); const activeTodos = createMemo(() => todos()); const activeWorkingFiles = createMemo(() => workingFiles()); const sessionActivity = (session: Session) => session.time?.updated ?? session.time?.created ?? 0; - const sortSessionsByActivity = (list: Session[]) => - list - .slice() - .sort((a, b) => { - const delta = sessionActivity(b) - sessionActivity(a); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - const [sessionsLoaded, setSessionsLoaded] = createSignal(false); const loadSessionsWithReady = async (scopeRoot?: string) => { await loadSessions(scopeRoot); @@ -1832,369 +1790,6 @@ export default function App() { }); } - const buildProviderAuthMethods = ( - methods: Record, - availableProviders: ProviderListItem[], - workerType: "local" | "remote", - ) => { - const merged = Object.fromEntries( - Object.entries(methods ?? {}).map(([id, providerMethods]) => [ - id, - (providerMethods ?? []).map((method, methodIndex) => ({ - ...method, - methodIndex, - })), - ]), - ) as Record; - for (const provider of availableProviders ?? []) { - const id = provider.id?.trim(); - if (!id || id === "opencode") continue; - if (!Array.isArray(provider.env) || provider.env.length === 0) continue; - const existing = merged[id] ?? []; - if (existing.some((method) => method.type === "api")) continue; - merged[id] = [...existing, { type: "api", label: "API key" }]; - } - for (const [id, providerMethods] of Object.entries(merged)) { - const provider = availableProviders.find((item) => item.id === id); - const normalizedId = id.trim().toLowerCase(); - const normalizedName = provider?.name?.trim().toLowerCase() ?? ""; - const isOpenAiProvider = normalizedId === "openai" || normalizedName === "openai"; - if (!isOpenAiProvider) continue; - merged[id] = providerMethods.filter((method) => { - if (method.type !== "oauth") return true; - const label = method.label.toLowerCase(); - const isHeadless = label.includes("headless") || label.includes("device"); - return workerType === "remote" ? isHeadless : !isHeadless; - }); - } - return merged; - }; - - const loadProviderAuthMethods = async (workerType: "local" | "remote") => { - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - const methods = unwrap(await c.provider.auth()); - return buildProviderAuthMethods( - methods as Record, - providers(), - workerType, - ); - }; - - async function startProviderAuth( - providerId?: string, - methodIndex?: number, - ): Promise { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - try { - const cachedMethods = providerAuthMethods(); - const workerType = selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; - const authMethods = Object.keys(cachedMethods).length - ? cachedMethods - : await loadProviderAuthMethods(workerType); - const providerIds = Object.keys(authMethods).sort(); - if (!providerIds.length) { - throw new Error("No providers available"); - } - - const resolved = providerId?.trim() ?? ""; - if (!resolved) { - throw new Error("Provider ID is required"); - } - - const methods = authMethods[resolved]; - if (!methods || !methods.length) { - throw new Error(`Unknown provider: ${resolved}`); - } - - const oauthIndex = - methodIndex !== undefined - ? methodIndex - : methods.find((method) => method.type === "oauth")?.methodIndex ?? -1; - if (oauthIndex === -1) { - throw new Error(`No OAuth flow available for ${resolved}. Use an API key instead.`); - } - - const selectedMethod = methods.find((method) => method.methodIndex === oauthIndex); - if (!selectedMethod || selectedMethod.type !== "oauth") { - throw new Error(`Selected auth method is not an OAuth flow for ${resolved}.`); - } - - const auth = unwrap(await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex })); - return { - methodIndex: oauthIndex, - authorization: auth, - }; - } catch (error) { - const message = describeProviderError(error, "Failed to connect provider"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function refreshProviders(options?: { dispose?: boolean }) { - const c = client(); - if (!c) return null; - - if (options?.dispose) { - try { - unwrap(await c.instance.dispose()); - } catch { - // ignore dispose failures and try reading current state anyway - } - - try { - await waitForHealthy(client() ?? c, { timeoutMs: 8_000, pollMs: 250 }); - } catch { - // ignore health wait failures and still attempt provider reads - } - } - - const activeClient = client() ?? c; - let disabledProviders = globalSync.data.config.disabled_providers ?? []; - try { - const config = unwrap(await activeClient.config.get()); - disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; - } catch { - // ignore config read failures and continue with current store state - } - try { - const updated = filterProviderList( - unwrap(await activeClient.provider.list()), - disabledProviders, - ); - globalSync.set("provider", updated); - return updated; - } catch { - try { - const fallback = unwrap(await activeClient.config.providers()); - const mapped = mapConfigProvidersToList(fallback.providers); - const previousConnected = providerConnectedIds(); - const next = filterProviderList( - { - all: mapped, - connected: previousConnected.filter((id) => mapped.some((provider) => provider.id === id)), - default: fallback.default, - }, - disabledProviders, - ); - globalSync.set("provider", next); - return next; - } catch { - return null; - } - } - } - - async function completeProviderAuthOAuth(providerId: string, methodIndex: number, code?: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const resolved = providerId?.trim(); - if (!resolved) { - throw new Error("Provider ID is required"); - } - - if (!Number.isInteger(methodIndex) || methodIndex < 0) { - throw new Error("OAuth method is required"); - } - - const waitForProviderConnection = async (timeoutMs = 15_000, pollMs = 2_000) => { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - const updated = await refreshProviders({ dispose: true }); - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return true; - } - } catch { - // ignore and retry - } - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - return false; - }; - - const isPendingOauthError = (error: unknown) => { - const text = error instanceof Error ? error.message : String(error ?? ""); - return /request timed out/i.test(text) || /ProviderAuthOauthMissing/i.test(text); - }; - - try { - const trimmedCode = code?.trim(); - const result = await c.provider.oauth.callback({ - providerID: resolved, - method: methodIndex, - code: trimmedCode || undefined, - }); - assertNoClientError(result); - const updated = await refreshProviders({ dispose: true }); - const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); - if (connectedNow) { - return { connected: true, message: `Connected ${resolved}` }; - } - const connected = await waitForProviderConnection(); - if (connected) { - return { connected: true, message: `Connected ${resolved}` }; - } - return { connected: false, pending: true }; - } catch (error) { - if (isPendingOauthError(error)) { - const updated = await refreshProviders({ dispose: true }); - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return { connected: true, message: `Connected ${resolved}` }; - } - const connected = await waitForProviderConnection(); - if (connected) { - return { connected: true, message: `Connected ${resolved}` }; - } - return { connected: false, pending: true }; - } - const message = describeProviderError(error, "Failed to complete OAuth"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function submitProviderApiKey(providerId: string, apiKey: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const trimmed = apiKey.trim(); - if (!trimmed) { - throw new Error("API key is required"); - } - - try { - await c.auth.set({ - providerID: providerId, - auth: { type: "api", key: trimmed }, - }); - await refreshProviders({ dispose: true }); - return `Connected ${providerId}`; - } catch (error) { - const message = describeProviderError(error, "Failed to save API key"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - - async function disconnectProvider(providerId: string) { - setProviderAuthError(null); - const c = client(); - if (!c) { - throw new Error("Not connected to a server"); - } - - const resolved = providerId.trim(); - if (!resolved) { - throw new Error("Provider ID is required"); - } - - const provider = providers().find((entry) => entry.id === resolved) as - | (ProviderListItem & { source?: string }) - | undefined; - const canDisableProvider = - provider?.source === "config" || provider?.source === "custom"; - - const removeProviderAuth = async () => { - const authClient = c.auth as unknown as { - remove?: (options: { providerID: string }) => Promise; - set?: (options: { providerID: string; auth: unknown }) => Promise; - }; - if (typeof authClient.remove === "function") { - const result = await authClient.remove({ providerID: resolved }); - assertNoClientError(result); - return; - } - - const rawClient = (c as unknown as { client?: { delete?: (options: { url: string }) => Promise } }) - .client; - if (rawClient?.delete) { - await rawClient.delete({ url: `/auth/${encodeURIComponent(resolved)}` }); - return; - } - - if (typeof authClient.set === "function") { - const result = await authClient.set({ providerID: resolved, auth: null }); - assertNoClientError(result); - return; - } - - throw new Error("Provider auth removal is not supported by this client."); - }; - - const disableProvider = async () => { - const config = unwrap(await c.config.get()); - const disabledProviders = Array.isArray(config.disabled_providers) - ? config.disabled_providers - : []; - if (disabledProviders.includes(resolved)) { - return false; - } - - const next = [...disabledProviders, resolved]; - globalSync.set("config", "disabled_providers", next); - try { - const result = await c.config.update({ - config: { - ...config, - disabled_providers: next, - }, - }); - assertNoClientError(result); - markOpencodeConfigReloadRequired(); - } catch (error) { - globalSync.set("config", "disabled_providers", disabledProviders); - throw error; - } - return true; - }; - - try { - await removeProviderAuth(); - let updated = await refreshProviders({ dispose: true }); - if ( - canDisableProvider && - Array.isArray(updated?.connected) && - updated.connected.includes(resolved) - ) { - const disabled = await disableProvider(); - if (disabled) { - updated = filterProviderList(updated, globalSync.data.config.disabled_providers ?? []); - globalSync.set("provider", updated); - } - if (!Array.isArray(updated?.connected) || !updated.connected.includes(resolved)) { - return disabled - ? `Disconnected ${resolved} and disabled it in OpenCode config.` - : `Disconnected ${resolved}.`; - } - } - - if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { - return `Removed stored credentials for ${resolved}, but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.`; - } - removeProviderFromState(resolved); - return `Disconnected ${resolved}`; - } catch (error) { - const message = describeProviderError(error, "Failed to disconnect provider"); - setProviderAuthError(message); - throw error instanceof Error ? error : new Error(message); - } - } - function focusSessionPromptSoon() { if (typeof window === "undefined" || currentView() !== "session") return; requestAnimationFrame(() => { @@ -2204,43 +1799,6 @@ export default function App() { }); } - async function openProviderAuthModal(options?: { - returnFocusTarget?: PromptFocusReturnTarget; - preferredProviderId?: string; - }) { - const workerType = selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; - setProviderAuthReturnFocusTarget(options?.returnFocusTarget ?? "none"); - setProviderAuthPreferredProviderId(options?.preferredProviderId?.trim() || null); - setProviderAuthBusy(true); - setProviderAuthError(null); - try { - const methods = await loadProviderAuthMethods(workerType); - setProviderAuthMethods(methods); - setProviderAuthModalOpen(true); - } catch (error) { - setProviderAuthPreferredProviderId(null); - setProviderAuthReturnFocusTarget("none"); - const message = describeProviderError(error, "Failed to load providers"); - setProviderAuthError(message); - throw error; - } finally { - setProviderAuthBusy(false); - } - } - - function closeProviderAuthModal(options?: { restorePromptFocus?: boolean }) { - const shouldFocusPrompt = - options?.restorePromptFocus ?? - providerAuthReturnFocusTarget() === "composer"; - setProviderAuthModalOpen(false); - setProviderAuthError(null); - setProviderAuthPreferredProviderId(null); - setProviderAuthReturnFocusTarget("none"); - if (shouldFocusPrompt) { - focusSessionPromptSoon(); - } - } - async function saveSessionExport(sessionID: string) { const c = client(); if (!c) { @@ -2400,16 +1958,6 @@ export default function App() { globalSync.set("provider", "connected", value); }; - const removeProviderFromState = (providerId: string) => { - const resolved = providerId.trim(); - if (!resolved) return; - setProviders(providers().filter((provider) => provider.id !== resolved)); - setProviderConnectedIds(providerConnectedIds().filter((id) => id !== resolved)); - setProviderDefaults( - Object.fromEntries(Object.entries(providerDefaults()).filter(([id]) => id !== resolved)), - ); - }; - const [defaultModel, setDefaultModel] = createSignal(DEFAULT_MODEL); const sessionModelOverridesKey = (workspaceId: string) => `${SESSION_MODEL_PREF_KEY}.${workspaceId}`; @@ -2725,6 +2273,35 @@ export default function App() { setPendingInitialSessionSelection, }); + const { + providerAuthModalOpen, + providerAuthBusy, + providerAuthError, + providerAuthMethods, + providerAuthPreferredProviderId, + providerAuthWorkerType, + startProviderAuth, + refreshProviders, + completeProviderAuthOAuth, + submitProviderApiKey, + disconnectProvider, + openProviderAuthModal, + closeProviderAuthModal, + } = createProvidersStore({ + client, + providers, + providerDefaults, + providerConnectedIds, + disabledProviders: () => globalSync.data.config.disabled_providers ?? [], + selectedWorkspaceDisplay: () => workspaceStore.selectedWorkspaceDisplay(), + setProviders, + setProviderDefaults, + setProviderConnectedIds, + setDisabledProviders: (value) => globalSync.set("config", "disabled_providers", value), + markOpencodeConfigReloadRequired: () => markOpencodeConfigReloadRequired(), + focusPromptSoon: focusSessionPromptSoon, + }); + const runtimeWorkspaceId = createMemo(() => workspaceStore.runtimeWorkspaceId()); const activeWorkspaceServerConfig = createMemo(() => workspaceStore.runtimeWorkspaceConfig()); @@ -6764,103 +6341,9 @@ export default function App() { return seconds > 0 ? `${label} ยท ${seconds}s` : label; }); - const localHostLabel = createMemo(() => { - const info = engine(); - if (info?.hostname && info?.port) { - return `${info.hostname}:${info.port}`; - } - - try { - return new URL(baseUrl()).host; - } catch { - return "localhost:4096"; - } - }); - - const onboardingProps = () => ({ - startupPreference: startupPreference(), - onboardingStep: onboardingStep(), - rememberStartupChoice: rememberStartupChoice(), - busy: busy(), - clientDirectory: clientDirectory(), - openworkHostUrl: openworkServerSettings().urlOverride ?? "", - openworkToken: openworkServerSettings().token ?? "", - newAuthorizedDir: newAuthorizedDir(), - authorizedDirs: workspaceStore.authorizedDirs(), - selectedWorkspacePath: workspaceStore.selectedWorkspacePath(), - workspaces: workspaceStore.workspaces(), - localHostLabel: localHostLabel(), - engineRunning: Boolean(engine()?.running), - developerMode: developerMode(), - engineBaseUrl: engine()?.baseUrl ?? null, - engineDoctorFound: engineDoctorResult()?.found ?? null, - engineDoctorSupportsServe: engineDoctorResult()?.supportsServe ?? null, - engineDoctorVersion: engineDoctorResult()?.version ?? null, - engineDoctorResolvedPath: engineDoctorResult()?.resolvedPath ?? null, - engineDoctorNotes: engineDoctorResult()?.notes ?? [], - engineDoctorServeHelpStdout: engineDoctorResult()?.serveHelpStdout ?? null, - engineDoctorServeHelpStderr: engineDoctorResult()?.serveHelpStderr ?? null, - engineDoctorCheckedAt: engineDoctorCheckedAt(), - engineInstallLogs: engineInstallLogs(), - error: error(), - canRepairMigration: workspaceStore.canRepairOpencodeMigration(), - migrationRepairUnavailableReason: migrationRepairUnavailableReason(), - migrationRepairBusy: workspaceStore.migrationRepairBusy(), - migrationRepairResult: workspaceStore.migrationRepairResult(), - isWindows: isWindowsPlatform(), - onClientDirectoryChange: setClientDirectory, - onOpenworkHostUrlChange: (value: string) => - updateOpenworkServerSettings({ - ...openworkServerSettings(), - urlOverride: value, - }), - onOpenworkTokenChange: (value: string) => - updateOpenworkServerSettings({ - ...openworkServerSettings(), - token: value, - }), - onSelectStartup: workspaceStore.onSelectStartup, - onRememberStartupToggle: workspaceStore.onRememberStartupToggle, - onStartHost: workspaceStore.onStartHost, - onRepairMigration: workspaceStore.onRepairOpencodeMigration, - onCreateWorkspace: workspaceStore.createWorkspaceFlow, - onPickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, - onImportWorkspaceConfig: workspaceStore.importWorkspaceConfig, - importingWorkspaceConfig: workspaceStore.importingWorkspaceConfig(), - onAttachHost: workspaceStore.onAttachHost, - onConnectClient: workspaceStore.onConnectClient, - onBackToWelcome: workspaceStore.onBackToWelcome, - onSetAuthorizedDir: workspaceStore.setNewAuthorizedDir, - onAddAuthorizedDir: workspaceStore.addAuthorizedDir, - onAddAuthorizedDirFromPicker: () => - workspaceStore.addAuthorizedDirFromPicker({ persistToWorkspace: true }), - onRemoveAuthorizedDir: workspaceStore.removeAuthorizedDirAtIndex, - onRefreshEngineDoctor: async () => { - workspaceStore.setEngineInstallLogs(null); - await workspaceStore.refreshEngineDoctor(); - }, - onInstallEngine: workspaceStore.onInstallEngine, - onShowSearchNotes: () => { - const notes = - workspaceStore.engineDoctorResult()?.notes?.join("\n") ?? ""; - workspaceStore.setEngineInstallLogs(notes || null); - }, - onOpenSettings: () => { - setTab("settings"); - setView("dashboard"); - }, - onOpenAdvancedSettings: () => { - setTab("config"); - setView("dashboard"); - }, - themeMode: themeMode(), - setThemeMode, - }); - const dashboardProps = () => { const workspaceType = selectedWorkspaceDisplay().workspaceType; const isRemoteWorkspace = workspaceType === "remote"; - const providerAuthWorkerType: "local" | "remote" = isRemoteWorkspace ? "remote" : "local"; const openworkStatus = openworkServerStatus(); const canUseDesktopTools = isTauriRuntime() && !isRemoteWorkspace; const canInstallSkillCreator = isRemoteWorkspace @@ -6901,7 +6384,7 @@ export default function App() { providerAuthError: providerAuthError(), providerAuthMethods: providerAuthMethods(), providerAuthPreferredProviderId: providerAuthPreferredProviderId(), - providerAuthWorkerType, + providerAuthWorkerType: providerAuthWorkerType(), openProviderAuthModal, disconnectProvider, closeProviderAuthModal, @@ -7176,9 +6659,7 @@ export default function App() { const sessionProps = () => ({ booting: booting(), - providerAuthWorkerType: (selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local") as - | "remote" - | "local", + providerAuthWorkerType: providerAuthWorkerType(), selectedSessionId: activeSessionId(), setView, tab: tab(), diff --git a/apps/app/src/app/context/providers/index.ts b/apps/app/src/app/context/providers/index.ts new file mode 100644 index 00000000..b6dd2c06 --- /dev/null +++ b/apps/app/src/app/context/providers/index.ts @@ -0,0 +1,3 @@ +export { createProvidersStore } from "./store"; +export type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; +export { default as ProviderAuthModal } from "./provider-auth-modal"; diff --git a/apps/app/src/app/components/provider-auth-modal.tsx b/apps/app/src/app/context/providers/provider-auth-modal.tsx similarity index 98% rename from apps/app/src/app/components/provider-auth-modal.tsx rename to apps/app/src/app/context/providers/provider-auth-modal.tsx index 0906e809..3b6248cf 100644 --- a/apps/app/src/app/components/provider-auth-modal.tsx +++ b/apps/app/src/app/context/providers/provider-auth-modal.tsx @@ -1,19 +1,14 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"; import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid"; -import type { ProviderListItem } from "../types"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; -import { isTauriRuntime } from "../utils"; -import { compareProviders } from "../utils/providers"; -import Button from "./button"; -import ProviderIcon from "./provider-icon"; -import TextInput from "./text-input"; +import type { ProviderListItem } from "../../types"; +import { isTauriRuntime } from "../../utils"; +import { compareProviders } from "../../utils/providers"; +import Button from "../../components/button"; +import ProviderIcon from "../../components/provider-icon"; +import TextInput from "../../components/text-input"; +import type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; -export type ProviderAuthMethod = { - type: "oauth" | "api"; - label: string; - methodIndex?: number; -}; type ProviderAuthEntry = { id: string; name: string; @@ -22,11 +17,6 @@ type ProviderAuthEntry = { env: string[]; }; -export type ProviderOAuthStartResult = { - methodIndex: number; - authorization: ProviderAuthAuthorization; -}; - type ProviderOAuthSession = ProviderOAuthStartResult & { providerId: string; methodLabel: string; @@ -706,7 +696,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
{entry.id}
- +
{(method) => ( diff --git a/apps/app/src/app/context/providers/store.ts b/apps/app/src/app/context/providers/store.ts new file mode 100644 index 00000000..f47ae415 --- /dev/null +++ b/apps/app/src/app/context/providers/store.ts @@ -0,0 +1,562 @@ +import { createMemo, createSignal, type Accessor } from "solid-js"; + +import type { ProviderAuthAuthorization, ProviderListResponse } from "@opencode-ai/sdk/v2/client"; + +import { unwrap, waitForHealthy } from "../../lib/opencode"; +import type { Client, ProviderListItem, WorkspaceDisplay } from "../../types"; +import { safeStringify } from "../../utils"; +import { filterProviderList, mapConfigProvidersToList } from "../../utils/providers"; + +type ProviderReturnFocusTarget = "none" | "composer"; + +export type ProviderAuthMethod = { + type: "oauth" | "api"; + label: string; + methodIndex?: number; +}; + +export type ProviderOAuthStartResult = { + methodIndex: number; + authorization: ProviderAuthAuthorization; +}; + +type CreateProvidersStoreOptions = { + client: Accessor; + providers: Accessor; + providerDefaults: Accessor>; + providerConnectedIds: Accessor; + disabledProviders: Accessor; + selectedWorkspaceDisplay: Accessor; + setProviders: (value: ProviderListItem[]) => void; + setProviderDefaults: (value: Record) => void; + setProviderConnectedIds: (value: string[]) => void; + setDisabledProviders: (value: string[]) => void; + markOpencodeConfigReloadRequired: () => void; + focusPromptSoon?: () => void; +}; + +export function createProvidersStore(options: CreateProvidersStoreOptions) { + const [providerAuthModalOpen, setProviderAuthModalOpen] = createSignal(false); + const [providerAuthBusy, setProviderAuthBusy] = createSignal(false); + const [providerAuthError, setProviderAuthError] = createSignal(null); + const [providerAuthMethods, setProviderAuthMethods] = createSignal>({}); + const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal(null); + const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] = + createSignal("none"); + + const providerAuthWorkerType = createMemo<"local" | "remote">(() => + options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local", + ); + + const applyProviderListState = (value: ProviderListResponse) => { + options.setProviders(value.all ?? []); + options.setProviderDefaults(value.default ?? {}); + options.setProviderConnectedIds(value.connected ?? []); + }; + + const removeProviderFromState = (providerId: string) => { + const resolved = providerId.trim(); + if (!resolved) return; + options.setProviders(options.providers().filter((provider) => provider.id !== resolved)); + options.setProviderConnectedIds(options.providerConnectedIds().filter((id) => id !== resolved)); + options.setProviderDefaults( + Object.fromEntries( + Object.entries(options.providerDefaults()).filter(([id]) => id !== resolved), + ), + ); + }; + + const assertNoClientError = (result: unknown) => { + const maybe = result as { error?: unknown } | null | undefined; + if (!maybe || maybe.error === undefined) return; + throw new Error(describeProviderError(maybe.error, "Request failed")); + }; + + const describeProviderError = (error: unknown, fallback: string) => { + const readString = (value: unknown, max = 700) => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, Math.max(0, max - 3))}...`; + }; + + const records: Record[] = []; + const root = error && typeof error === "object" ? (error as Record) : null; + if (root) { + records.push(root); + if (root.data && typeof root.data === "object") records.push(root.data as Record); + if (root.cause && typeof root.cause === "object") { + const cause = root.cause as Record; + records.push(cause); + if (cause.data && typeof cause.data === "object") records.push(cause.data as Record); + } + } + + const firstString = (keys: string[]) => { + for (const record of records) { + for (const key of keys) { + const value = readString(record[key]); + if (value) return value; + } + } + return null; + }; + + const firstNumber = (keys: string[]) => { + for (const record of records) { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + } + return null; + }; + + const status = firstNumber(["statusCode", "status"]); + const provider = firstString(["providerID", "providerId", "provider"]); + const code = firstString(["code", "errorCode"]); + const response = firstString(["responseBody", "body", "response"]); + const raw = + (error instanceof Error ? readString(error.message) : null) || + firstString(["message", "detail", "reason", "error"]) || + (typeof error === "string" ? readString(error) : null); + + const generic = raw && /^unknown\s+error$/i.test(raw); + const heading = (() => { + if (status === 401 || status === 403) return "Authentication failed"; + if (status === 429) return "Rate limit exceeded"; + if (provider) return `Provider error (${provider})`; + return fallback; + })(); + + const lines = [heading]; + if (raw && !generic && raw !== heading) lines.push(raw); + if (status && !heading.includes(String(status))) lines.push(`Status: ${status}`); + if (provider && !heading.includes(provider)) lines.push(`Provider: ${provider}`); + if (code) lines.push(`Code: ${code}`); + if (response) lines.push(`Response: ${response}`); + if (lines.length > 1) return lines.join("\n"); + + if (raw && !generic) return raw; + if (error && typeof error === "object") { + const serialized = safeStringify(error); + if (serialized && serialized !== "{}") return serialized; + } + return fallback; + }; + + const buildProviderAuthMethods = ( + methods: Record, + availableProviders: ProviderListItem[], + workerType: "local" | "remote", + ) => { + const merged = Object.fromEntries( + Object.entries(methods ?? {}).map(([id, providerMethods]) => [ + id, + (providerMethods ?? []).map((method, methodIndex) => ({ + ...method, + methodIndex, + })), + ]), + ) as Record; + for (const provider of availableProviders ?? []) { + const id = provider.id?.trim(); + if (!id || id === "opencode") continue; + if (!Array.isArray(provider.env) || provider.env.length === 0) continue; + const existing = merged[id] ?? []; + if (existing.some((method) => method.type === "api")) continue; + merged[id] = [...existing, { type: "api", label: "API key" }]; + } + for (const [id, providerMethods] of Object.entries(merged)) { + const provider = availableProviders.find((item) => item.id === id); + const normalizedId = id.trim().toLowerCase(); + const normalizedName = provider?.name?.trim().toLowerCase() ?? ""; + const isOpenAiProvider = normalizedId === "openai" || normalizedName === "openai"; + if (!isOpenAiProvider) continue; + merged[id] = providerMethods.filter((method) => { + if (method.type !== "oauth") return true; + const label = method.label.toLowerCase(); + const isHeadless = label.includes("headless") || label.includes("device"); + return workerType === "remote" ? isHeadless : !isHeadless; + }); + } + return merged; + }; + + const loadProviderAuthMethods = async (workerType: "local" | "remote") => { + const c = options.client(); + if (!c) { + throw new Error("Not connected to a server"); + } + const methods = unwrap(await c.provider.auth()); + return buildProviderAuthMethods( + methods as Record, + options.providers(), + workerType, + ); + }; + + async function startProviderAuth( + providerId?: string, + methodIndex?: number, + ): Promise { + setProviderAuthError(null); + const c = options.client(); + if (!c) { + throw new Error("Not connected to a server"); + } + try { + const cachedMethods = providerAuthMethods(); + const authMethods = Object.keys(cachedMethods).length + ? cachedMethods + : await loadProviderAuthMethods(providerAuthWorkerType()); + const providerIds = Object.keys(authMethods).sort(); + if (!providerIds.length) { + throw new Error("No providers available"); + } + + const resolved = providerId?.trim() ?? ""; + if (!resolved) { + throw new Error("Provider ID is required"); + } + + const methods = authMethods[resolved]; + if (!methods || !methods.length) { + throw new Error(`Unknown provider: ${resolved}`); + } + + const oauthIndex = + methodIndex !== undefined + ? methodIndex + : methods.find((method) => method.type === "oauth")?.methodIndex ?? -1; + if (oauthIndex === -1) { + throw new Error(`No OAuth flow available for ${resolved}. Use an API key instead.`); + } + + const selectedMethod = methods.find((method) => method.methodIndex === oauthIndex); + if (!selectedMethod || selectedMethod.type !== "oauth") { + throw new Error(`Selected auth method is not an OAuth flow for ${resolved}.`); + } + + const auth = unwrap(await c.provider.oauth.authorize({ providerID: resolved, method: oauthIndex })); + return { + methodIndex: oauthIndex, + authorization: auth, + }; + } catch (error) { + const message = describeProviderError(error, "Failed to connect provider"); + setProviderAuthError(message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function refreshProviders(optionsArg?: { dispose?: boolean }) { + const c = options.client(); + if (!c) return null; + + if (optionsArg?.dispose) { + try { + unwrap(await c.instance.dispose()); + } catch { + // ignore dispose failures and try reading current state anyway + } + + try { + await waitForHealthy(options.client() ?? c, { timeoutMs: 8_000, pollMs: 250 }); + } catch { + // ignore health wait failures and still attempt provider reads + } + } + + const activeClient = options.client() ?? c; + let disabledProviders = options.disabledProviders() ?? []; + try { + const config = unwrap(await activeClient.config.get()); + disabledProviders = Array.isArray(config.disabled_providers) ? config.disabled_providers : []; + options.setDisabledProviders(disabledProviders); + } catch { + // ignore config read failures and continue with current store state + } + try { + const updated = filterProviderList( + unwrap(await activeClient.provider.list()), + disabledProviders, + ); + applyProviderListState(updated); + return updated; + } catch { + try { + const fallback = unwrap(await activeClient.config.providers()); + const mapped = mapConfigProvidersToList(fallback.providers); + const next = filterProviderList( + { + all: mapped, + connected: options.providerConnectedIds().filter((id) => mapped.some((provider) => provider.id === id)), + default: fallback.default, + }, + disabledProviders, + ); + applyProviderListState(next); + return next; + } catch { + return null; + } + } + } + + async function completeProviderAuthOAuth(providerId: string, methodIndex: number, code?: string) { + setProviderAuthError(null); + const c = options.client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const resolved = providerId?.trim(); + if (!resolved) { + throw new Error("Provider ID is required"); + } + + if (!Number.isInteger(methodIndex) || methodIndex < 0) { + throw new Error("OAuth method is required"); + } + + const waitForProviderConnection = async (timeoutMs = 15_000, pollMs = 2_000) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const updated = await refreshProviders({ dispose: true }); + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return true; + } + } catch { + // ignore and retry + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + return false; + }; + + const isPendingOauthError = (error: unknown) => { + const text = error instanceof Error ? error.message : String(error ?? ""); + return /request timed out/i.test(text) || /ProviderAuthOauthMissing/i.test(text); + }; + + try { + const trimmedCode = code?.trim(); + const result = await c.provider.oauth.callback({ + providerID: resolved, + method: methodIndex, + code: trimmedCode || undefined, + }); + assertNoClientError(result); + const updated = await refreshProviders({ dispose: true }); + const connectedNow = Array.isArray(updated?.connected) && updated.connected.includes(resolved); + if (connectedNow) { + return { connected: true, message: `Connected ${resolved}` }; + } + const connected = await waitForProviderConnection(); + if (connected) { + return { connected: true, message: `Connected ${resolved}` }; + } + return { connected: false, pending: true }; + } catch (error) { + if (isPendingOauthError(error)) { + const updated = await refreshProviders({ dispose: true }); + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return { connected: true, message: `Connected ${resolved}` }; + } + const connected = await waitForProviderConnection(); + if (connected) { + return { connected: true, message: `Connected ${resolved}` }; + } + return { connected: false, pending: true }; + } + const message = describeProviderError(error, "Failed to complete OAuth"); + setProviderAuthError(message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function submitProviderApiKey(providerId: string, apiKey: string) { + setProviderAuthError(null); + const c = options.client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error("API key is required"); + } + + try { + await c.auth.set({ + providerID: providerId, + auth: { type: "api", key: trimmed }, + }); + await refreshProviders({ dispose: true }); + return `Connected ${providerId}`; + } catch (error) { + const message = describeProviderError(error, "Failed to save API key"); + setProviderAuthError(message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function disconnectProvider(providerId: string) { + setProviderAuthError(null); + const c = options.client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const resolved = providerId.trim(); + if (!resolved) { + throw new Error("Provider ID is required"); + } + + const provider = options.providers().find((entry) => entry.id === resolved) as + | (ProviderListItem & { source?: string }) + | undefined; + const canDisableProvider = + provider?.source === "config" || provider?.source === "custom"; + + const removeProviderAuth = async () => { + const authClient = c.auth as unknown as { + remove?: (options: { providerID: string }) => Promise; + set?: (options: { providerID: string; auth: unknown }) => Promise; + }; + if (typeof authClient.remove === "function") { + const result = await authClient.remove({ providerID: resolved }); + assertNoClientError(result); + return; + } + + const rawClient = (c as unknown as { client?: { delete?: (options: { url: string }) => Promise } }) + .client; + if (rawClient?.delete) { + await rawClient.delete({ url: `/auth/${encodeURIComponent(resolved)}` }); + return; + } + + if (typeof authClient.set === "function") { + const result = await authClient.set({ providerID: resolved, auth: null }); + assertNoClientError(result); + return; + } + + throw new Error("Provider auth removal is not supported by this client."); + }; + + const disableProvider = async () => { + const config = unwrap(await c.config.get()); + const disabledProviders = Array.isArray(config.disabled_providers) + ? config.disabled_providers + : []; + if (disabledProviders.includes(resolved)) { + return false; + } + + const next = [...disabledProviders, resolved]; + options.setDisabledProviders(next); + try { + const result = await c.config.update({ + config: { + ...config, + disabled_providers: next, + }, + }); + assertNoClientError(result); + options.markOpencodeConfigReloadRequired(); + } catch (error) { + options.setDisabledProviders(disabledProviders); + throw error; + } + return true; + }; + + try { + await removeProviderAuth(); + let updated = await refreshProviders({ dispose: true }); + if ( + canDisableProvider && + Array.isArray(updated?.connected) && + updated.connected.includes(resolved) + ) { + const disabled = await disableProvider(); + if (disabled && updated) { + updated = filterProviderList(updated, options.disabledProviders() ?? []); + applyProviderListState(updated); + } + if (!Array.isArray(updated?.connected) || !updated.connected.includes(resolved)) { + return disabled + ? `Disconnected ${resolved} and disabled it in OpenCode config.` + : `Disconnected ${resolved}.`; + } + } + + if (Array.isArray(updated?.connected) && updated.connected.includes(resolved)) { + return `Removed stored credentials for ${resolved}, but the worker still reports it as connected. Clear any remaining API key or OAuth credentials and restart the worker to fully disconnect.`; + } + removeProviderFromState(resolved); + return `Disconnected ${resolved}`; + } catch (error) { + const message = describeProviderError(error, "Failed to disconnect provider"); + setProviderAuthError(message); + throw error instanceof Error ? error : new Error(message); + } + } + + async function openProviderAuthModal(optionsArg?: { + returnFocusTarget?: ProviderReturnFocusTarget; + preferredProviderId?: string; + }) { + setProviderAuthReturnFocusTarget(optionsArg?.returnFocusTarget ?? "none"); + setProviderAuthPreferredProviderId(optionsArg?.preferredProviderId?.trim() || null); + setProviderAuthBusy(true); + setProviderAuthError(null); + try { + const methods = await loadProviderAuthMethods(providerAuthWorkerType()); + setProviderAuthMethods(methods); + setProviderAuthModalOpen(true); + } catch (error) { + setProviderAuthPreferredProviderId(null); + setProviderAuthReturnFocusTarget("none"); + const message = describeProviderError(error, "Failed to load providers"); + setProviderAuthError(message); + throw error; + } finally { + setProviderAuthBusy(false); + } + } + + function closeProviderAuthModal(optionsArg?: { restorePromptFocus?: boolean }) { + const shouldFocusPrompt = + optionsArg?.restorePromptFocus ?? + providerAuthReturnFocusTarget() === "composer"; + setProviderAuthModalOpen(false); + setProviderAuthError(null); + setProviderAuthPreferredProviderId(null); + setProviderAuthReturnFocusTarget("none"); + if (shouldFocusPrompt) { + options.focusPromptSoon?.(); + } + } + + return { + providerAuthModalOpen, + providerAuthBusy, + providerAuthError, + providerAuthMethods, + providerAuthPreferredProviderId, + providerAuthWorkerType, + startProviderAuth, + refreshProviders, + completeProviderAuthOAuth, + submitProviderApiKey, + disconnectProvider, + openProviderAuthModal, + closeProviderAuthModal, + }; +} diff --git a/apps/app/src/app/pages/dashboard.tsx b/apps/app/src/app/pages/dashboard.tsx index 9f243874..ef73f636 100644 --- a/apps/app/src/app/pages/dashboard.tsx +++ b/apps/app/src/app/pages/dashboard.tsx @@ -50,10 +50,10 @@ import Button from "../components/button"; import ConfigView from "./config"; import SettingsView from "./settings"; import StatusBar from "../components/status-bar"; -import ProviderAuthModal, { +import { ProviderAuthModal, type ProviderAuthMethod, type ProviderOAuthStartResult, -} from "../components/provider-auth-modal"; +} from "../context/providers"; import ShareWorkspaceModal from "../components/share-workspace-modal"; import WorkspaceSessionList from "../components/session/workspace-session-list"; import WorkspaceToolsPanel from "./workspace-tools-panel"; diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index 582a9a7c..6d821e62 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -65,10 +65,10 @@ import { import Button from "../components/button"; import ConfirmModal from "../components/confirm-modal"; import RenameSessionModal from "../components/rename-session-modal"; -import ProviderAuthModal, { +import { ProviderAuthModal, type ProviderAuthMethod, type ProviderOAuthStartResult, -} from "../components/provider-auth-modal"; +} from "../context/providers"; import ShareWorkspaceModal from "../components/share-workspace-modal"; import StatusBar from "../components/status-bar"; import {