mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(react-app): restore desktop bootstrap and reconnect flow (#1520)
* fix(react-app): restore workspace-scoped provider bootstrap Load providers and default model state from the selected workspace on first render so the React desktop flow matches the pre-cutover app without requiring a modal refresh. * fix(react-app): restore desktop workspace reconnect flow Keep desktop workspaces visible when bootstrap refreshes fail, log reconnect/bootstrap failures in the React inspector, and reattach local workspaces to the OpenWork host automatically so sessions and settings recover after workspace changes. --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<LocalUIState>(() =>
|
||||
readPersisted(UI_STORAGE_KEY, INITIAL_UI),
|
||||
);
|
||||
const [prefs, setPrefsRaw] = useState<LocalPreferences>(() =>
|
||||
readPersisted(PREFS_STORAGE_KEY, INITIAL_PREFS),
|
||||
);
|
||||
const [prefs, setPrefsRaw] = useState<LocalPreferences>(() => {
|
||||
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);
|
||||
|
||||
|
||||
112
apps/app/src/react-app/shell/desktop-local-openwork.ts
Normal file
112
apps/app/src/react-app/shell/desktop-local-openwork.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, any[]>,
|
||||
@@ -228,11 +291,13 @@ export function SessionRoute() {
|
||||
const [workspaces, setWorkspaces] = useState<RouteWorkspace[]>([]);
|
||||
const [sessionsByWorkspaceId, setSessionsByWorkspaceId] = useState<Record<string, any[]>>({});
|
||||
const [errorsByWorkspaceId, setErrorsByWorkspaceId] = useState<Record<string, string | null>>({});
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(() => readActiveWorkspaceId() ?? "");
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(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<RouteWorkspace[]>([]);
|
||||
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<ModelOption[]>([]);
|
||||
const [providers, setProviders] = useState<ProviderListItem[]>([]);
|
||||
const [providerConnectedIds, setProviderConnectedIds] = useState<string[]>([]);
|
||||
// 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<EngineInfo | null>(null);
|
||||
const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = useState(false);
|
||||
const [shareRemoteAccessError, setShareRemoteAccessError] = useState<string | null>(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<ReturnType<typeof workspaceBootstrap>> | 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 }),
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const workspacesRef = useRef<RouteWorkspace[]>([]);
|
||||
const reconnectAttemptedWorkspaceIdRef = useRef("");
|
||||
const [providers, setProviders] = useState<ProviderListItem[]>([]);
|
||||
const [providerDefaults, setProviderDefaults] = useState<Record<string, string>>({});
|
||||
const [providerConnectedIds, setProviderConnectedIds] = useState<string[]>([]);
|
||||
@@ -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<ReturnType<typeof workspaceBootstrap>> | 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 <Navigate to={route.redirectPath} replace />;
|
||||
|
||||
Reference in New Issue
Block a user