diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index feffd496..45e73cab 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -338,11 +338,12 @@ export function SessionSurface(props: SessionSurfaceProps) { setSending(true); try { const nextDraft = buildDraft(text, attachments); - props.onSendDraft(nextDraft); + await props.onSendDraft(nextDraft); setDraft(""); attachments.forEach(revokeAttachmentPreview); setAttachments([]); props.onDraftChange(buildDraft("", [])); + setSending(false); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "Failed to send prompt."); setSending(false); diff --git a/apps/app/src/react-app/kernel/local-provider.tsx b/apps/app/src/react-app/kernel/local-provider.tsx index 7a9c8ab7..c8867aa3 100644 --- a/apps/app/src/react-app/kernel/local-provider.tsx +++ b/apps/app/src/react-app/kernel/local-provider.tsx @@ -13,6 +13,7 @@ import { import { THINKING_PREF_KEY } from "../../app/constants"; import { coerceReleaseChannel } from "../../app/lib/release-channels"; import type { ModelRef, ReleaseChannel, SettingsTab, View } from "../../app/types"; +import { readStoredDefaultModel } from "./model-config"; export type LocalUIState = { view: View; @@ -88,9 +89,16 @@ export function LocalProvider({ children }: LocalProviderProps) { const [ui, setUiRaw] = useState(() => readPersisted(UI_STORAGE_KEY, INITIAL_UI), ); - const [prefs, setPrefsRaw] = useState(() => - readPersisted(PREFS_STORAGE_KEY, INITIAL_PREFS), - ); + const [prefs, setPrefsRaw] = useState(() => { + const persisted = readPersisted(PREFS_STORAGE_KEY, INITIAL_PREFS); + if (persisted.defaultModel) { + return persisted; + } + return { + ...persisted, + defaultModel: readStoredDefaultModel(), + }; + }); const [ready, setReady] = useState(false); const migratedThinkingRef = useRef(false); diff --git a/apps/app/src/react-app/shell/desktop-local-openwork.ts b/apps/app/src/react-app/shell/desktop-local-openwork.ts new file mode 100644 index 00000000..7d893dae --- /dev/null +++ b/apps/app/src/react-app/shell/desktop-local-openwork.ts @@ -0,0 +1,112 @@ +import { + engineInfo, + engineStart, + openworkServerInfo, + orchestratorWorkspaceActivate, +} from "../../app/lib/tauri"; +import { writeOpenworkServerSettings } from "../../app/lib/openwork-server"; +import { safeStringify } from "../../app/utils"; +import { recordInspectorEvent } from "./app-inspector"; + +type LocalWorkspaceLike = { + id: string; + name?: string | null; + displayNameResolved?: string | null; + path?: string | null; + workspaceType?: "local" | "remote" | string | null; +}; + +type EnsureDesktopLocalOpenworkOptions = { + route: "session" | "settings"; + workspace: LocalWorkspaceLike | null | undefined; + allWorkspaces: LocalWorkspaceLike[]; +}; + +function emitOpenworkSettingsChanged() { + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + // ignore browser event dispatch failures + } +} + +function describeError(error: unknown) { + if (error instanceof Error) return error.message; + const serialized = safeStringify(error); + return serialized && serialized !== "{}" ? serialized : "Unknown error"; +} + +export async function ensureDesktopLocalOpenworkConnection( + options: EnsureDesktopLocalOpenworkOptions, +) { + const workspace = options.workspace; + const workspaceRoot = workspace?.path?.trim() ?? ""; + if (!workspace || workspace.workspaceType !== "local" || !workspaceRoot) { + return null; + } + + const workspacePaths = Array.from( + new Set( + options.allWorkspaces + .filter((item) => item.workspaceType === "local") + .map((item) => item.path?.trim() ?? "") + .filter((path) => path.length > 0), + ), + ); + if (!workspacePaths.includes(workspaceRoot)) { + workspacePaths.unshift(workspaceRoot); + } + + recordInspectorEvent("route.local_openwork.ensure.start", { + route: options.route, + workspaceId: workspace.id, + workspaceRoot, + }); + + try { + const engine = await engineInfo().catch(() => null); + if (!engine?.running || !engine.baseUrl) { + await engineStart(workspaceRoot, { + runtime: "openwork-orchestrator", + workspacePaths, + }); + } + + await orchestratorWorkspaceActivate({ + workspacePath: workspaceRoot, + name: workspace.name ?? workspace.displayNameResolved ?? null, + }); + + const info = await openworkServerInfo(); + if (!info?.baseUrl) { + throw new Error("OpenWork server did not report a base URL after activation."); + } + + writeOpenworkServerSettings({ + urlOverride: info.baseUrl, + token: info.ownerToken?.trim() || info.clientToken?.trim() || undefined, + portOverride: info.port ?? undefined, + remoteAccessEnabled: info.remoteAccessEnabled === true, + }); + emitOpenworkSettingsChanged(); + + recordInspectorEvent("route.local_openwork.ensure.success", { + route: options.route, + workspaceId: workspace.id, + workspaceRoot, + baseUrl: info.baseUrl, + }); + + return info; + } catch (error) { + const message = describeError(error); + console.error(`[${options.route}-route] local workspace reconnect failed`, error); + recordInspectorEvent("route.local_openwork.ensure.error", { + route: options.route, + workspaceId: workspace.id, + workspaceRoot, + message, + }); + throw new Error(message); + } +} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 05dcc04b..d54f33d2 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -1,9 +1,15 @@ /** @jsxImportSource react */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import type { AgentPartInput, FilePartInput, TextPartInput } from "@opencode-ai/sdk/v2/client"; +import type { + AgentPartInput, + ConfigProvidersResponse, + FilePartInput, + ProviderListResponse, + TextPartInput, +} from "@opencode-ai/sdk/v2/client"; -import { unwrap } from "../../app/lib/opencode"; +import { createClient, unwrap } from "../../app/lib/opencode"; import { listCommands, shellInSession } from "../../app/lib/opencode-session"; import { buildOpenworkWorkspaceBaseUrl, @@ -43,11 +49,11 @@ import type { TodoItem, WorkspacePreset, WorkspaceConnectionState, + ProviderListItem, WorkspaceSessionGroup, } from "../../app/types"; -import { createClient } from "../../app/lib/opencode"; import { buildFeedbackUrl } from "../../app/lib/feedback"; -import { isSandboxWorkspace, isTauriRuntime, normalizeDirectoryPath } from "../../app/utils"; +import { isSandboxWorkspace, isTauriRuntime, normalizeDirectoryPath, safeStringify } from "../../app/utils"; import { t } from "../../i18n"; import { useLocal } from "../kernel/local-provider"; import { usePlatform } from "../kernel/platform"; @@ -75,6 +81,8 @@ import { recordInspectorEvent, } from "./app-inspector"; import { getModelBehaviorSummary } from "../../app/lib/model-behavior"; +import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers"; +import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; type RouteWorkspace = OpenworkWorkspaceInfo & { displayNameResolved: string; @@ -133,6 +141,61 @@ function workspaceLabel(workspace: OpenworkWorkspaceInfo) { ); } +function describeRouteError(error: unknown) { + if (error instanceof Error) { + return error.message; + } + const serialized = safeStringify(error); + return serialized && serialized !== "{}" ? serialized : t("app.unknown_error"); +} + +function mergeRouteWorkspaces( + serverWorkspaces: OpenworkWorkspaceInfo[], + desktopWorkspaces: RouteWorkspace[], +): RouteWorkspace[] { + const desktopById = new Map(desktopWorkspaces.map((workspace) => [workspace.id, workspace])); + const desktopByPath = new Map( + desktopWorkspaces + .map((workspace) => [normalizeDirectoryPath(workspace.path ?? ""), workspace] as const) + .filter(([path]) => path.length > 0), + ); + + const mergedServer = serverWorkspaces.map((workspace) => { + const match = + desktopById.get(workspace.id) ?? + desktopByPath.get(normalizeDirectoryPath(workspace.path ?? "")); + const merged = match + ? { + ...workspace, + displayName: match.displayName?.trim() + ? match.displayName + : workspace.displayName, + name: match.name?.trim() ? match.name : workspace.name, + } + : workspace; + return { + ...merged, + displayNameResolved: workspaceLabel(merged), + }; + }); + + const mergedIds = new Set(mergedServer.map((workspace) => workspace.id)); + const mergedPaths = new Set( + mergedServer + .map((workspace) => normalizeDirectoryPath(workspace.path ?? "")) + .filter((path) => path.length > 0), + ); + + const missingDesktop = desktopWorkspaces.filter((workspace) => { + if (mergedIds.has(workspace.id)) return false; + const normalizedPath = normalizeDirectoryPath(workspace.path ?? ""); + if (normalizedPath && mergedPaths.has(normalizedPath)) return false; + return true; + }); + + return [...mergedServer, ...missingDesktop]; +} + function toSessionGroups( workspaces: RouteWorkspace[], sessionsByWorkspaceId: Record, @@ -228,11 +291,13 @@ export function SessionRoute() { const [workspaces, setWorkspaces] = useState([]); const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = useState>({}); const [errorsByWorkspaceId, setErrorsByWorkspaceId] = useState>({}); + const [routeError, setRouteError] = useState(null); const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => readActiveWorkspaceId() ?? ""); const [selectedAgent, setSelectedAgent] = useState(null); // One-way latch for "a refreshRouteState is currently running"; prevents // overlapping route refreshes from queueing up when the user clicks fast. const refreshInFlightRef = useRef(false); + const workspacesRef = useRef([]); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false); const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false); const [createWorkspaceRemoteBusy, setCreateWorkspaceRemoteBusy] = useState(false); @@ -247,6 +312,8 @@ export function SessionRoute() { const [modelPickerOpen, setModelPickerOpen] = useState(false); const [modelPickerQuery, setModelPickerQuery] = useState(""); const [modelOptions, setModelOptions] = useState([]); + const [providers, setProviders] = useState([]); + const [providerConnectedIds, setProviderConnectedIds] = useState([]); // Provider catalog cache. Used to compute the reasoning/thinking variant // options for whichever model is currently selected so the composer's // behavior pill actually shows its options (bug: was empty before). @@ -256,6 +323,7 @@ export function SessionRoute() { const [routeEngineInfo, setRouteEngineInfo] = useState(null); const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = useState(false); const [shareRemoteAccessError, setShareRemoteAccessError] = useState(null); + const reconnectAttemptedWorkspaceIdRef = useRef(""); const openworkServerSettings = useMemo( () => readOpenworkServerSettings(), @@ -280,9 +348,25 @@ export function SessionRoute() { if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; setLoading(true); + setRouteError(null); + let desktopList = null as Awaited> | null; + let desktopWorkspaces = workspacesRef.current; try { - const desktopList = isTauriRuntime() ? await workspaceBootstrap().catch(() => null) : null; - const desktopWorkspaces = (desktopList?.workspaces ?? []).map(mapDesktopWorkspace); + if (isTauriRuntime()) { + try { + desktopList = await workspaceBootstrap(); + desktopWorkspaces = (desktopList.workspaces ?? []).map(mapDesktopWorkspace); + } catch (error) { + const message = describeRouteError(error); + console.error("[session-route] workspaceBootstrap failed", error); + recordInspectorEvent("route.workspace_bootstrap.error", { + route: "session", + message, + preservedWorkspaceCount: workspacesRef.current.length, + }); + desktopWorkspaces = workspacesRef.current; + } + } const { normalizedBaseUrl, resolvedToken, hostInfo } = await resolveRouteOpenworkConnection(); setOpenworkServerHostInfoState(hostInfo); @@ -302,50 +386,7 @@ export function SessionRoute() { token: resolvedToken, }); const list = await openworkClient.listWorkspaces(); - const desktopById = new Map(desktopWorkspaces.map((workspace) => [workspace.id, workspace])); - const desktopByPath = new Map( - desktopWorkspaces - .map((workspace) => [normalizeDirectoryPath(workspace.path ?? ""), workspace] as const) - .filter(([path]) => path.length > 0), - ); - - const baseRouteWorkspaces = list.items.map((workspace) => { - const match = - desktopById.get(workspace.id) ?? - desktopByPath.get(normalizeDirectoryPath(workspace.path ?? "")); - const merged = match - ? { - ...workspace, - displayName: match.displayName?.trim() - ? match.displayName - : workspace.displayName, - name: match.name?.trim() ? match.name : workspace.name, - } - : workspace; - return { - ...merged, - displayNameResolved: workspaceLabel(merged), - }; - }); - - // If the user removed a workspace locally, the server may still know - // about it until its --workspace list gets reconciled. Hide rows that - // the desktop has forgotten so rename/delete feel instant. - const routeWorkspaces = desktopWorkspaces.length === 0 - ? baseRouteWorkspaces - : baseRouteWorkspaces.filter((workspace) => { - if (desktopById.has(workspace.id)) return true; - const normalized = normalizeDirectoryPath(workspace.path ?? ""); - return normalized.length > 0 && desktopByPath.has(normalized); - }); - - const desktopRemotes = desktopWorkspaces.filter( - (workspace) => workspace.workspaceType === "remote", - ); - const nextWorkspaces = - routeWorkspaces.length > 0 - ? [...routeWorkspaces, ...desktopRemotes] - : desktopWorkspaces; + const nextWorkspaces = mergeRouteWorkspaces(list.items, desktopWorkspaces); const sessionEntries = await Promise.all( nextWorkspaces.map(async (workspace) => { @@ -405,6 +446,21 @@ export function SessionRoute() { selectedWorkspaceId: nextWorkspaceId, errors: Object.fromEntries(sessionEntries.filter((e) => e.error).map((e) => [e.workspaceId, e.error])), }); + } catch (error) { + const message = describeRouteError(error); + console.error("[session-route] refreshRouteState failed", error); + recordInspectorEvent("route.refresh.error", { + route: "session", + message, + preservedWorkspaceCount: desktopWorkspaces.length, + }); + setRouteError(message); + if (desktopWorkspaces.length > 0) { + setWorkspaces(desktopWorkspaces); + setSelectedWorkspaceId((current) => + current || resolveWorkspaceListSelectedId(desktopList) || desktopWorkspaces[0]?.id || "", + ); + } } finally { setLoading(false); refreshInFlightRef.current = false; @@ -415,6 +471,10 @@ export function SessionRoute() { } }, [markBootRouteReady, selectedSessionId]); + useEffect(() => { + workspacesRef.current = workspaces; + }, [workspaces]); + useEffect(() => { let cancelled = false; @@ -483,6 +543,7 @@ export function SessionRoute() { baseUrl, tokenPresent: token.length > 0, connected: Boolean(client), + routeError, selectedSessionId, selectedWorkspaceId, persistedActiveWorkspaceId: readActiveWorkspaceId(), @@ -513,6 +574,7 @@ export function SessionRoute() { loading, selectedSessionId, selectedWorkspaceId, + routeError, sessionsByWorkspaceId, token, workspaces, @@ -549,6 +611,28 @@ export function SessionRoute() { [selectedWorkspaceId, workspaces], ); + useEffect(() => { + if (!isTauriRuntime()) return; + if (loading) return; + if (client) { + reconnectAttemptedWorkspaceIdRef.current = ""; + return; + } + if (!selectedWorkspace || selectedWorkspace.workspaceType !== "local") return; + const workspaceId = selectedWorkspace.id?.trim() ?? ""; + if (!workspaceId || reconnectAttemptedWorkspaceIdRef.current === workspaceId) return; + reconnectAttemptedWorkspaceIdRef.current = workspaceId; + + void ensureDesktopLocalOpenworkConnection({ + route: "session", + workspace: selectedWorkspace, + allWorkspaces: workspaces, + }).catch((error) => { + const message = error instanceof Error ? error.message : describeRouteError(error); + setRouteError(message); + }); + }, [client, loading, selectedWorkspace, workspaces]); + const selectedWorkspaceRoot = selectedWorkspace?.path?.trim() || ""; const opencodeBaseUrl = useMemo(() => { if (!selectedWorkspaceId || !baseUrl) return ""; @@ -559,11 +643,83 @@ export function SessionRoute() { const opencodeClient = useMemo( () => opencodeBaseUrl && token - ? createClient(opencodeBaseUrl, undefined, { token, mode: "openwork" }) + ? createClient(opencodeBaseUrl, selectedWorkspaceRoot || undefined, { + token, + mode: "openwork", + }) : null, - [opencodeBaseUrl, token], + [opencodeBaseUrl, selectedWorkspaceRoot, token], ); + useEffect(() => { + if (!opencodeClient) { + setProviders([]); + setProviderConnectedIds([]); + return; + } + + let cancelled = false; + + const applyProviderState = (value: ProviderListResponse) => { + if (cancelled) return; + setProviders((value.all ?? []) as ProviderListItem[]); + setProviderConnectedIds(value.connected ?? []); + }; + + void (async () => { + let disabledProviders: string[] = []; + try { + const config = unwrap( + await opencodeClient.config.get({ + directory: selectedWorkspaceRoot || undefined, + }), + ) as { disabled_providers?: string[] }; + disabledProviders = Array.isArray(config.disabled_providers) + ? config.disabled_providers + : []; + } catch { + // ignore config read failures and continue with provider discovery + } + + try { + applyProviderState( + filterProviderList( + unwrap(await opencodeClient.provider.list()), + disabledProviders, + ), + ); + } catch { + try { + const fallback = unwrap( + await opencodeClient.config.providers({ + directory: selectedWorkspaceRoot || undefined, + }), + ) as ConfigProvidersResponse; + applyProviderState( + filterProviderList( + { + all: mapConfigProvidersToList( + fallback.providers, + ) as ProviderListResponse["all"], + connected: [], + default: fallback.default, + }, + disabledProviders, + ), + ); + } catch { + if (cancelled) return; + setProviders([]); + setProviderConnectedIds([]); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [opencodeClient, selectedWorkspaceRoot]); + const modelLabel = local.prefs.defaultModel ? `${local.prefs.defaultModel.providerID}/${local.prefs.defaultModel.modelID}` : t("session.default_model"); @@ -752,6 +908,7 @@ export function SessionRoute() { const result = await opencodeClient.session.promptAsync({ sessionID: selectedSessionId, parts, + model: local.prefs.defaultModel ?? undefined, agent: selectedAgent ?? undefined, ...(local.prefs.modelVariant ? { variant: local.prefs.modelVariant } : {}), }); @@ -968,7 +1125,11 @@ export function SessionRoute() { const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace || !token || !baseUrl) return; const workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`; - const workspaceClient = createClient(workspaceOpencodeBaseUrl, undefined, { token, mode: "openwork" }); + const workspaceClient = createClient( + workspaceOpencodeBaseUrl, + workspace.path?.trim() || undefined, + { token, mode: "openwork" }, + ); const session = unwrap( await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }), ); @@ -1137,8 +1298,8 @@ export function SessionRoute() { headerStatus={client ? t("status.connected") : t("status.disconnected_label")} busyHint={loading ? t("session.loading_detail") : null} startupPhase={loading ? "nativeInit" : "ready"} - providerConnectedIds={[]} - providers={[]} + providerConnectedIds={providerConnectedIds} + providers={providers} mcpConnectedCount={0} onSendFeedback={() => { platform.openLink( @@ -1193,7 +1354,11 @@ export function SessionRoute() { const workspace = workspaces.find((item) => item.id === workspaceId); if (!workspace || !token || !baseUrl) return; const workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`; - const workspaceClient = createClient(workspaceOpencodeBaseUrl, undefined, { token, mode: "openwork" }); + const workspaceClient = createClient( + workspaceOpencodeBaseUrl, + workspace.path?.trim() || undefined, + { token, mode: "openwork" }, + ); const session = unwrap( await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }), ); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 4b26e932..cd74d14c 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -59,10 +59,12 @@ import { import { isDesktopProviderBlocked } from "../../app/cloud/desktop-app-restrictions"; import { useCheckDesktopRestriction } from "../domains/cloud/desktop-config-provider"; import { useCloudProviderAutoSync } from "../domains/cloud/use-cloud-provider-auto-sync"; -import { isMacPlatform, isTauriRuntime, normalizeDirectoryPath } from "../../app/utils"; +import { isMacPlatform, isTauriRuntime, normalizeDirectoryPath, safeStringify } from "../../app/utils"; import { CreateWorkspaceModal } from "../domains/workspace/create-workspace-modal"; import { ModelPickerModal } from "../domains/session/modals/model-picker-modal"; import type { ModelOption, ModelRef } from "../../app/types"; +import { recordInspectorEvent } from "./app-inspector"; +import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; type RouteWorkspace = OpenworkWorkspaceInfo & { displayNameResolved: string; @@ -79,6 +81,61 @@ function mapDesktopWorkspace(workspace: WorkspaceInfo): RouteWorkspace { }; } +function describeRouteError(error: unknown) { + if (error instanceof Error) { + return error.message; + } + const serialized = safeStringify(error); + return serialized && serialized !== "{}" ? serialized : t("app.unknown_error"); +} + +function mergeRouteWorkspaces( + serverWorkspaces: OpenworkWorkspaceInfo[], + desktopWorkspaces: RouteWorkspace[], +): RouteWorkspace[] { + const desktopById = new Map(desktopWorkspaces.map((workspace) => [workspace.id, workspace])); + const desktopByPath = new Map( + desktopWorkspaces + .map((workspace) => [normalizeDirectoryPath(workspace.path ?? ""), workspace] as const) + .filter(([path]) => path.length > 0), + ); + + const mergedServer = serverWorkspaces.map((workspace) => { + const match = + desktopById.get(workspace.id) ?? + desktopByPath.get(normalizeDirectoryPath(workspace.path ?? "")); + const merged = match + ? { + ...workspace, + displayName: match.displayName?.trim() + ? match.displayName + : workspace.displayName, + name: match.name?.trim() ? match.name : workspace.name, + } + : workspace; + return { + ...merged, + displayNameResolved: workspaceLabel(merged), + }; + }); + + const mergedIds = new Set(mergedServer.map((workspace) => workspace.id)); + const mergedPaths = new Set( + mergedServer + .map((workspace) => normalizeDirectoryPath(workspace.path ?? "")) + .filter((path) => path.length > 0), + ); + + const missingDesktop = desktopWorkspaces.filter((workspace) => { + if (mergedIds.has(workspace.id)) return false; + const normalizedPath = normalizeDirectoryPath(workspace.path ?? ""); + if (normalizedPath && mergedPaths.has(normalizedPath)) return false; + return true; + }); + + return [...mergedServer, ...missingDesktop]; +} + function folderNameFromPath(path: string) { const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); const parts = normalized.split("/").filter(Boolean); @@ -233,6 +290,8 @@ export function SettingsRoute() { const [busy, setBusy] = useState(false); const [busyLabel, setBusyLabel] = useState(null); const [routeError, setRouteError] = useState(null); + const workspacesRef = useRef([]); + const reconnectAttemptedWorkspaceIdRef = useRef(""); const [providers, setProviders] = useState([]); const [providerDefaults, setProviderDefaults] = useState>({}); const [providerConnectedIds, setProviderConnectedIds] = useState([]); @@ -438,9 +497,12 @@ export function SettingsRoute() { const opencodeClient = useMemo( () => opencodeBaseUrl && token - ? createClient(opencodeBaseUrl, undefined, { token, mode: "openwork" }) + ? createClient(opencodeBaseUrl, selectedWorkspaceRoot || undefined, { + token, + mode: "openwork", + }) : null, - [opencodeBaseUrl, token], + [opencodeBaseUrl, selectedWorkspaceRoot, token], ); useEffect(() => { @@ -520,9 +582,24 @@ export function SettingsRoute() { const refreshRouteState = useMemo(() => async () => { setLoading(true); setRouteError(null); + let desktopList = null as Awaited> | null; + let desktopWorkspaces = workspacesRef.current; try { - const desktopList = isTauriRuntime() ? await workspaceBootstrap().catch(() => null) : null; - const desktopWorkspaces = (desktopList?.workspaces ?? []).map(mapDesktopWorkspace); + if (isTauriRuntime()) { + try { + desktopList = await workspaceBootstrap(); + desktopWorkspaces = (desktopList.workspaces ?? []).map(mapDesktopWorkspace); + } catch (error) { + const message = describeRouteError(error); + console.error("[settings-route] workspaceBootstrap failed", error); + recordInspectorEvent("route.workspace_bootstrap.error", { + route: "settings", + message, + preservedWorkspaceCount: workspacesRef.current.length, + }); + desktopWorkspaces = workspacesRef.current; + } + } const { normalizedBaseUrl, resolvedToken } = await resolveRouteOpenworkConnection(); if (!normalizedBaseUrl || !resolvedToken) { @@ -538,21 +615,7 @@ export function SettingsRoute() { const client = createOpenworkServerClient({ baseUrl: normalizedBaseUrl, token: resolvedToken }); const list = await client.listWorkspaces(); - const serverWorkspaces = list.items.map((workspace) => ({ - ...workspace, - displayNameResolved: workspaceLabel(workspace), - })); - // Mirror Solid's `applyServerLocalWorkspaces`: prefer server-registered - // local workspaces (their IDs are what subsequent API calls need) and - // append any remote entries from the desktop list since the server - // doesn't track remotes. - const desktopRemotes = desktopWorkspaces.filter( - (workspace) => workspace.workspaceType === "remote", - ); - const nextWorkspaces = - serverWorkspaces.length > 0 - ? [...serverWorkspaces, ...desktopRemotes] - : desktopWorkspaces; + const nextWorkspaces = mergeRouteWorkspaces(list.items, desktopWorkspaces); const sessionEntries = await Promise.all( nextWorkspaces.map(async (workspace) => { try { @@ -582,7 +645,18 @@ export function SettingsRoute() { setErrorsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.error]))); setSelectedWorkspaceId((current) => current || resolveWorkspaceListSelectedId(desktopList) || list.activeId?.trim() || nextWorkspaces[0]?.id || ""); } catch (error) { - setRouteError(error instanceof Error ? error.message : t("app.unknown_error")); + const message = describeRouteError(error); + console.error("[settings-route] refreshRouteState failed", error); + recordInspectorEvent("route.refresh.error", { + route: "settings", + message, + preservedWorkspaceCount: desktopWorkspaces.length, + }); + setRouteError(message); + if (desktopWorkspaces.length > 0) { + setWorkspaces(desktopWorkspaces); + setSelectedWorkspaceId((current) => current || resolveWorkspaceListSelectedId(desktopList) || desktopWorkspaces[0]?.id || ""); + } } finally { setLoading(false); // Settings can be the first route a user lands on (direct link, deep @@ -592,6 +666,32 @@ export function SettingsRoute() { } }, [markBootRouteReady]); + useEffect(() => { + workspacesRef.current = workspaces; + }, [workspaces]); + + useEffect(() => { + if (!isTauriRuntime()) return; + if (loading) return; + if (openworkClient) { + reconnectAttemptedWorkspaceIdRef.current = ""; + return; + } + if (!selectedWorkspace || selectedWorkspace.workspaceType !== "local") return; + const workspaceId = selectedWorkspace.id?.trim() ?? ""; + if (!workspaceId || reconnectAttemptedWorkspaceIdRef.current === workspaceId) return; + reconnectAttemptedWorkspaceIdRef.current = workspaceId; + + void ensureDesktopLocalOpenworkConnection({ + route: "settings", + workspace: selectedWorkspace, + allWorkspaces: workspaces, + }).catch((error) => { + const message = error instanceof Error ? error.message : describeRouteError(error); + setRouteError(message); + }); + }, [loading, openworkClient, selectedWorkspace, workspaces]); + useEffect(() => { void refreshRouteState(); const handleSettingsChange = () => { @@ -644,7 +744,7 @@ export function SettingsRoute() { ]); useEffect(() => { - if (!opencodeClient) { + if (!activeClient) { setProviders([]); setProviderDefaults({}); setProviderConnectedIds([]); @@ -653,7 +753,7 @@ export function SettingsRoute() { } void providerAuthStore.refreshProviders(); void connectionsStore.refreshMcpServers(); - }, [connectionsStore, opencodeClient, providerAuthStore, selectedWorkspace?.id]); + }, [activeClient, connectionsStore, providerAuthStore, selectedWorkspace?.id]); if (route.redirectPath) { return ;