fix React startup and reload flows

This commit is contained in:
Benjamin Shafii
2026-04-23 22:43:51 -07:00
parent 9517d0397e
commit 50e350bc19
9 changed files with 542 additions and 113 deletions

View File

@@ -23,6 +23,7 @@ import {
usesChromeDevtoolsAutoConnect,
validateMcpServerName,
} from "../../../app/mcp";
import { buildOpenworkWorkspaceBaseUrl } from "../../../app/lib/openwork-server";
import type {
Client,
McpServerEntry,
@@ -171,7 +172,9 @@ export function createConnectionsStore(options: {
return null;
}
activeClient = createClient(`${openworkBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, {
const mountedBaseUrl =
buildOpenworkWorkspaceBaseUrl(openworkBaseUrl, options.runtimeWorkspaceId()) ?? openworkBaseUrl;
activeClient = createClient(`${mountedBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, {
token,
mode: "openwork",
});
@@ -530,30 +533,40 @@ export function createConnectionsStore(options: {
}
}
const mcpAddConfig =
entryType === "remote"
? {
type: "remote" as const,
url: entry.url!,
enabled: true,
...(entry.oauth ? { oauth: {} } : {}),
}
: {
type: "local" as const,
command: entry.command!,
enabled: true,
...(mcpEnvironment ? { environment: mcpEnvironment } : {}),
};
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
// The OpenWork server is the source of truth for workspace-scoped MCP
// config in the React port. Avoid also calling the OpenCode SDK's MCP
// hot-add endpoint here: when the SDK client is rooted at the aggregate
// `/opencode` route it can resolve to an internal `local_*` workspace
// id that the OpenWork server does not expose, producing a confusing
// `workspace_not_found` after the config write already succeeded.
setStateField("mcpStatuses", filterConfiguredStatuses(snapshot.mcpStatuses, snapshot.mcpServers));
} else {
const mcpAddConfig =
entryType === "remote"
? {
type: "remote" as const,
url: entry.url!,
enabled: true,
...(entry.oauth ? { oauth: {} } : {}),
}
: {
type: "local" as const,
command: entry.command!,
enabled: true,
...(mcpEnvironment ? { environment: mcpEnvironment } : {}),
};
const status = unwrap(
await activeClient.mcp.add({
directory: resolvedProjectDir,
name: slug,
config: mcpAddConfig,
}),
);
const status = unwrap(
await activeClient.mcp.add({
directory: resolvedProjectDir,
name: slug,
config: mcpAddConfig,
}),
);
setStateField("mcpStatuses", status as McpStatusMap);
setStateField("mcpStatuses", status as McpStatusMap);
}
options.markReloadRequired?.("mcp", { type: "mcp", name: slug, action });
await refreshMcpServers();

View File

@@ -143,6 +143,14 @@ export function ServerProvider({ children, defaultUrl }: ServerProviderProps) {
useEffect(() => {
if (!active) return;
if (isDesktopRuntime() && !active.includes("/opencode")) {
// Desktop React routes now talk to OpenWork server workspace-mounted
// `/opencode` URLs directly. Ignore old persisted raw OpenCode daemon
// URLs here; their ephemeral ports go stale across restarts and otherwise
// produce noisy `/global/health` connection-refused polling forever.
setHealthy(undefined);
return;
}
setHealthy(undefined);
let cancelled = false;

View File

@@ -49,7 +49,7 @@ const PHASE_MESSAGES: Record<BootPhaseId, string> = {
idle: "",
"bootstrapping-workspaces": "Loading your workspaces",
"starting-openwork-server": "Starting the OpenWork server",
"starting-engine": "Starting OpenCode",
"starting-engine": "Preparing workspace",
"activating-workspace": "Activating your workspace",
ready: "Ready",
error: "Something went wrong",

View File

@@ -130,7 +130,7 @@ export function useDesktopRuntimeBoot() {
if (!workspacePaths.includes(path)) workspacePaths.push(path);
}
setPhase("starting-engine", "Launching the OpenCode engine");
setPhase("starting-engine", "Starting your workspace");
const engineStartResult = await engineStart(workspaceRoot, {
runtime: "openwork-orchestrator",
workspacePaths,

View File

@@ -13,6 +13,7 @@ import { BootStateProvider } from "./boot-state";
import { DesktopRuntimeBoot } from "./desktop-runtime-boot";
import { startDebugLogger, stopDebugLogger } from "./debug-logger";
import { MigrationPrompt } from "./migration-prompt";
import { ReloadCoordinatorProvider } from "./reload-coordinator";
function resolveDefaultServerUrl(): string {
if (isDesktopRuntime()) return "http://127.0.0.1:4096";
@@ -63,7 +64,9 @@ export function AppProviders({ children }: AppProvidersProps) {
<DenAuthProvider>
<DesktopConfigProvider>
<RestrictionNoticeProvider>
<LocalProvider>{children}</LocalProvider>
<LocalProvider>
<ReloadCoordinatorProvider>{children}</ReloadCoordinatorProvider>
</LocalProvider>
</RestrictionNoticeProvider>
</DesktopConfigProvider>
</DenAuthProvider>

View File

@@ -0,0 +1,149 @@
/** @jsxImportSource react */
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { ReloadReason, ReloadTrigger } from "../../app/types";
import { t } from "../../i18n";
import { ReloadWorkspaceToast } from "../domains/shell-feedback/reload-workspace-toast";
import { useSystemState } from "../kernel/system-state";
type ReloadSession = { id: string; title: string };
export type WorkspaceReloadControls = {
canReloadWorkspaceEngine: () => boolean;
reloadWorkspaceEngine: () => Promise<boolean>;
activeSessions?: () => ReloadSession[];
stopSession?: (sessionId: string) => void | Promise<void>;
};
type ReloadCoordinatorContextValue = {
markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void;
clearReloadRequired: () => void;
reloadWorkspaceEngine: () => Promise<void>;
canReloadWorkspaceEngine: boolean;
registerWorkspaceReloadControls: (controls: WorkspaceReloadControls | null) => () => void;
};
const ReloadCoordinatorContext = createContext<ReloadCoordinatorContextValue | null>(null);
export function ReloadCoordinatorProvider({ children }: { children: ReactNode }) {
const controlsRef = useRef<WorkspaceReloadControls | null>(null);
const [activeSessions, setActiveSessions] = useState<ReloadSession[]>([]);
const registerWorkspaceReloadControls = useCallback((controls: WorkspaceReloadControls | null) => {
controlsRef.current = controls;
setActiveSessions(controls?.activeSessions?.() ?? []);
return () => {
if (controlsRef.current === controls) {
controlsRef.current = null;
setActiveSessions([]);
}
};
}, []);
const hasActiveRuns = useCallback(() => activeSessions.length > 0, [activeSessions.length]);
const canReloadWorkspaceEngine = useCallback(
() => controlsRef.current?.canReloadWorkspaceEngine() === true,
[],
);
const reloadWorkspaceEngine = useCallback(async () => {
const controls = controlsRef.current;
if (!controls?.reloadWorkspaceEngine) return false;
return controls.reloadWorkspaceEngine();
}, []);
const ignoreError = useCallback(() => {}, []);
const systemStateOptions = useMemo(
() => ({
hasActiveRuns,
canReloadWorkspaceEngine,
reloadWorkspaceEngine,
setError: ignoreError,
}),
[canReloadWorkspaceEngine, hasActiveRuns, ignoreError, reloadWorkspaceEngine],
);
const systemState = useSystemState(systemStateOptions);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ reason?: ReloadReason; trigger?: ReloadTrigger }>).detail;
systemState.markReloadRequired(detail?.reason ?? "config", detail?.trigger);
};
window.addEventListener("openwork-reload-required", handler);
return () => window.removeEventListener("openwork-reload-required", handler);
}, [systemState.markReloadRequired]);
const forceStopActiveSessionsAndReload = useCallback(async () => {
const controls = controlsRef.current;
if (controls?.stopSession) {
for (const session of activeSessions) {
await Promise.resolve(controls.stopSession(session.id)).catch(() => undefined);
}
}
await systemState.reloadWorkspaceEngine();
}, [activeSessions, systemState.reloadWorkspaceEngine]);
const value = useMemo<ReloadCoordinatorContextValue>(
() => ({
markReloadRequired: systemState.markReloadRequired,
clearReloadRequired: systemState.clearReloadRequired,
reloadWorkspaceEngine: systemState.reloadWorkspaceEngine,
canReloadWorkspaceEngine: systemState.canReloadWorkspaceEngine,
registerWorkspaceReloadControls,
}),
[
registerWorkspaceReloadControls,
systemState.canReloadWorkspaceEngine,
systemState.clearReloadRequired,
systemState.markReloadRequired,
systemState.reloadWorkspaceEngine,
],
);
return (
<ReloadCoordinatorContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 w-[min(24rem,calc(100vw-1.5rem))] max-w-full sm:right-6 sm:top-6">
<div className="pointer-events-auto">
<ReloadWorkspaceToast
open={systemState.reload.reloadPending}
title={systemState.reloadCopy.title}
description={systemState.reloadCopy.body}
trigger={systemState.reload.reloadTrigger}
error={systemState.reload.reloadError}
reloadLabel={
activeSessions.length > 0 ? t("app.reload_stop_tasks") : t("app.reload_now")
}
dismissLabel={t("app.reload_later")}
busy={systemState.reload.reloadBusy}
canReload={systemState.canReloadWorkspaceEngine}
hasActiveRuns={activeSessions.length > 0}
onReload={() => {
void (activeSessions.length > 0
? forceStopActiveSessionsAndReload()
: systemState.reloadWorkspaceEngine());
}}
onDismiss={systemState.clearReloadRequired}
/>
</div>
</div>
</ReloadCoordinatorContext.Provider>
);
}
export function useReloadCoordinator(): ReloadCoordinatorContextValue {
const value = useContext(ReloadCoordinatorContext);
if (!value) {
throw new Error("useReloadCoordinator must be used inside <ReloadCoordinatorProvider>");
}
return value;
}

View File

@@ -85,6 +85,7 @@ import { useReactRenderWatchdog } from "./react-render-watchdog";
import { getModelBehaviorSummary } from "../../app/lib/model-behavior";
import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers";
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
import { useReloadCoordinator } from "./reload-coordinator";
type RouteWorkspace = OpenworkWorkspaceInfo & {
displayNameResolved: string;
@@ -133,6 +134,44 @@ async function resolveRouteOpenworkConnection() {
return { normalizedBaseUrl, resolvedToken, hostInfo };
}
async function checkWorkspaceOpencodeReady(
baseUrl: string,
workspaceId: string,
token: string,
timeoutMs = 1_500,
) {
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl;
const url = `${mounted.replace(/\/+$/, "")}/opencode/global/health`;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
let timeoutId: number | null = null;
try {
if (controller) {
timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
}
const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
signal: controller?.signal,
});
if (!response.ok) return false;
const data = await response.json().catch(() => null);
return data?.healthy === true;
} catch {
return false;
} finally {
if (timeoutId) window.clearTimeout(timeoutId);
}
}
function isTransientStartupError(message: string | null | undefined) {
const value = (message ?? "").toLowerCase();
return (
value.includes("timed out") ||
value.includes("failed to fetch") ||
value.includes("connection") ||
value.includes("not ready")
);
}
function workspaceLabel(workspace: OpenworkWorkspaceInfo) {
return (
workspace.displayName?.trim() ||
@@ -202,11 +241,16 @@ function toSessionGroups(
workspaces: RouteWorkspace[],
sessionsByWorkspaceId: Record<string, any[]>,
errorsByWorkspaceId: Record<string, string | null>,
loadingWorkspaceIds: Set<string>,
): WorkspaceSessionGroup[] {
return workspaces.map((workspace) => ({
workspace,
sessions: (sessionsByWorkspaceId[workspace.id] ?? []) as WorkspaceSessionGroup["sessions"],
status: errorsByWorkspaceId[workspace.id] ? "error" : "ready",
status: loadingWorkspaceIds.has(workspace.id)
? "loading"
: errorsByWorkspaceId[workspace.id]
? "error"
: "ready",
error: errorsByWorkspaceId[workspace.id],
}));
}
@@ -280,6 +324,7 @@ export function SessionRoute() {
const navigate = useNavigate();
const platform = usePlatform();
const local = useLocal();
const reloadCoordinator = useReloadCoordinator();
const checkDesktopRestriction = useCheckDesktopRestriction();
const restrictionNotice = useRestrictionNotice();
const params = useParams<{ sessionId?: string }>();
@@ -299,7 +344,11 @@ export function SessionRoute() {
// 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 reloadEventCursorByWorkspaceRef = useRef<Record<string, number | null>>({});
const workspacesRef = useRef<RouteWorkspace[]>([]);
const sessionsByWorkspaceIdRef = useRef<Record<string, any[]>>({});
const startupRetryTimerRef = useRef<number | null>(null);
const [retryingWorkspaceIds, setRetryingWorkspaceIds] = useState<string[]>([]);
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false);
const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false);
const [createWorkspaceRemoteBusy, setCreateWorkspaceRemoteBusy] = useState(false);
@@ -362,6 +411,7 @@ export function SessionRoute() {
setRouteError(null);
let desktopList = null as Awaited<ReturnType<typeof workspaceBootstrap>> | null;
let desktopWorkspaces = workspacesRef.current;
let routeReadyAfterRefresh = true;
try {
if (isDesktopRuntime()) {
try {
@@ -402,6 +452,27 @@ export function SessionRoute() {
const sessionEntries = await Promise.all(
nextWorkspaces.map(async (workspace) => {
try {
const opencodeReady = await checkWorkspaceOpencodeReady(
normalizedBaseUrl,
workspace.id,
resolvedToken,
);
if (!opencodeReady) {
return {
workspaceId: workspace.id,
sessions: sessionsByWorkspaceIdRef.current[workspace.id] ?? [],
error: null as string | null,
loading: true,
};
}
if (!selectedSessionId) {
return {
workspaceId: workspace.id,
sessions: sessionsByWorkspaceIdRef.current[workspace.id] ?? [],
error: null as string | null,
loading: false,
};
}
const response = await openworkClient.listSessions(workspace.id, { limit: 200 });
// The underlying opencode instance is shared across all local
// workspaces attached to the same openwork-server, so `listSessions`
@@ -414,16 +485,41 @@ export function SessionRoute() {
normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot,
)
: (response.items ?? []);
return { workspaceId: workspace.id, sessions: items, error: null as string | null };
return { workspaceId: workspace.id, sessions: items, error: null as string | null, loading: false };
} catch (error) {
const message = error instanceof Error ? error.message : t("app.unknown_error");
if (isTransientStartupError(message)) {
return {
workspaceId: workspace.id,
sessions: sessionsByWorkspaceIdRef.current[workspace.id] ?? [],
error: null as string | null,
loading: true,
};
}
return {
workspaceId: workspace.id,
sessions: [],
error: error instanceof Error ? error.message : t("app.unknown_error"),
error: message,
loading: false,
};
}
}),
);
const retryingIds = sessionEntries
.filter((entry) => entry.loading)
.map((entry) => entry.workspaceId);
routeReadyAfterRefresh = retryingIds.length === 0;
setRetryingWorkspaceIds(retryingIds);
if (retryingIds.length > 0 && typeof window !== "undefined" && startupRetryTimerRef.current === null) {
startupRetryTimerRef.current = window.setTimeout(() => {
startupRetryTimerRef.current = null;
refreshInFlightRef.current = false;
void refreshRouteState();
}, 1_000);
} else if (retryingIds.length === 0 && startupRetryTimerRef.current !== null) {
window.clearTimeout(startupRetryTimerRef.current);
startupRetryTimerRef.current = null;
}
// Prefer, in order: the URL-selected workspace (if it owns the session),
// the user's last-active workspace from localStorage, the desktop's
@@ -478,14 +574,112 @@ export function SessionRoute() {
// Tell the boot overlay the first route data load has completed so
// the overlay dismisses after BOTH the desktop boot and the workspace
// list/sessions are ready.
markBootRouteReady();
if (routeReadyAfterRefresh) {
markBootRouteReady();
}
}
}, [markBootRouteReady, selectedSessionId]);
const reloadWorkspaceEngineFromUi = useCallback(async () => {
if (!client || !selectedWorkspaceId) {
setRouteError(t("app.error_connect_first"));
return false;
}
await client.reloadEngine(selectedWorkspaceId);
try {
window.dispatchEvent(new CustomEvent("openwork-server-settings-changed"));
} catch {
// ignore browser event dispatch failures
}
await refreshRouteState();
return true;
}, [client, refreshRouteState, selectedWorkspaceId]);
useEffect(() => {
return reloadCoordinator.registerWorkspaceReloadControls({
canReloadWorkspaceEngine: () => Boolean(client && selectedWorkspaceId),
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
activeSessions: () => [],
});
}, [client, reloadCoordinator, reloadWorkspaceEngineFromUi, selectedWorkspaceId]);
useEffect(() => {
if (!client || !selectedWorkspaceId) return;
let cancelled = false;
const pollReloadEvents = async () => {
const currentCursor = reloadEventCursorByWorkspaceRef.current[selectedWorkspaceId];
try {
const response = await client.listReloadEvents(
selectedWorkspaceId,
typeof currentCursor === "number" ? { since: currentCursor } : undefined,
);
if (cancelled) return;
reloadEventCursorByWorkspaceRef.current[selectedWorkspaceId] =
typeof response.cursor === "number"
? response.cursor
: Math.max(currentCursor ?? 0, ...((response.items ?? []).map((item: any) => Number(item.seq) || 0)));
// The first poll establishes the server cursor so historical reload
// events don't show a stale toast on route entry. Subsequent polls mark
// new filesystem/server-side mutations, including skills created by an
// agent while the session page is open.
if (currentCursor === undefined || currentCursor === null) return;
for (const event of response.items ?? []) {
reloadCoordinator.markReloadRequired(event.reason, event.trigger);
}
} catch {
// Reload-event polling is best-effort; normal route health checks still
// surface connection failures.
}
};
void pollReloadEvents();
const interval = window.setInterval(() => void pollReloadEvents(), 3000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [client, reloadCoordinator, selectedWorkspaceId]);
useEffect(() => {
if (!client || !selectedWorkspaceId || !selectedSessionId) return;
let cancelled = false;
const refreshSelectedSessionTitle = async () => {
try {
const response = await client.getSession(selectedWorkspaceId, selectedSessionId);
if (cancelled || !response.item) return;
setSessionsByWorkspaceId((current) => {
const list = current[selectedWorkspaceId] ?? [];
const index = list.findIndex((session: any) => session?.id === selectedSessionId);
if (index < 0) return current;
const nextSession = { ...list[index], ...response.item };
if (JSON.stringify(nextSession) === JSON.stringify(list[index])) return current;
const nextList = [...list];
nextList[index] = nextSession;
return { ...current, [selectedWorkspaceId]: nextList };
});
} catch {
// Best-effort title sync; the session surface still owns messages.
}
};
void refreshSelectedSessionTitle();
const interval = window.setInterval(() => void refreshSelectedSessionTitle(), 3_000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [client, selectedSessionId, selectedWorkspaceId]);
useEffect(() => {
workspacesRef.current = workspaces;
}, [workspaces]);
useEffect(() => {
sessionsByWorkspaceIdRef.current = sessionsByWorkspaceId;
}, [sessionsByWorkspaceId]);
useEffect(() => {
let cancelled = false;
@@ -522,6 +716,10 @@ export function SessionRoute() {
return () => {
cancelled = true;
if (startupRetryTimerRef.current !== null) {
window.clearTimeout(startupRetryTimerRef.current);
startupRetryTimerRef.current = null;
}
window.removeEventListener("openwork-server-settings-changed", handleSettingsChange);
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibility);
@@ -551,6 +749,7 @@ export function SessionRoute() {
useEffect(() => {
const dispose = publishInspectorSlice("route", () => ({
loading,
retryingWorkspaceIds,
baseUrl,
tokenPresent: token.length > 0,
connected: Boolean(client),
@@ -564,6 +763,7 @@ export function SessionRoute() {
workspaceType: workspace.workspaceType,
path: workspace.path,
sessionCount: (sessionsByWorkspaceId[workspace.id] ?? []).length,
loading: retryingWorkspaceIds.includes(workspace.id),
error: errorsByWorkspaceId[workspace.id] ?? null,
})),
sessionsByWorkspaceId: Object.fromEntries(
@@ -583,6 +783,7 @@ export function SessionRoute() {
client,
errorsByWorkspaceId,
loading,
retryingWorkspaceIds,
selectedSessionId,
selectedWorkspaceId,
routeError,
@@ -613,8 +814,8 @@ export function SessionRoute() {
// the onboarding flow, not from the route effect loop.
const workspaceSessionGroups = useMemo(
() => toSessionGroups(workspaces, sessionsByWorkspaceId, errorsByWorkspaceId),
[errorsByWorkspaceId, sessionsByWorkspaceId, workspaces],
() => toSessionGroups(workspaces, sessionsByWorkspaceId, errorsByWorkspaceId, new Set(retryingWorkspaceIds)),
[errorsByWorkspaceId, retryingWorkspaceIds, sessionsByWorkspaceId, workspaces],
);
const selectedWorkspace = useMemo(
@@ -650,16 +851,22 @@ export function SessionRoute() {
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, selectedWorkspaceId) ?? baseUrl;
return `${mounted.replace(/\/+$|\/+$/g, "")}/opencode`;
}, [baseUrl, selectedWorkspaceId]);
const selectedWorkspaceIsLoading = retryingWorkspaceIds.includes(selectedWorkspaceId);
const selectedWorkspaceError = errorsByWorkspaceId[selectedWorkspaceId] ?? null;
const effectiveLoading = loading || selectedWorkspaceIsLoading;
const opencodeClient = useMemo(
() =>
opencodeBaseUrl && token
opencodeBaseUrl && token && !effectiveLoading && !selectedWorkspaceError
? createClient(opencodeBaseUrl, selectedWorkspaceRoot || undefined, {
token,
mode: "openwork",
})
: null,
[opencodeBaseUrl, selectedWorkspaceRoot, token],
[effectiveLoading, opencodeBaseUrl, selectedWorkspaceError, selectedWorkspaceRoot, token],
);
const canCreateTask = Boolean(
opencodeClient && selectedWorkspaceId && !effectiveLoading && !selectedWorkspaceError,
);
useEffect(() => {
@@ -1132,18 +1339,42 @@ export function SessionRoute() {
const handleCreateTaskInWorkspace = useCallback(async (workspaceId: string) => {
const workspace = workspaces.find((item) => item.id === workspaceId);
if (!workspace || !token || !baseUrl) return;
if (
!workspace ||
!token ||
!baseUrl ||
loading ||
retryingWorkspaceIds.includes(workspaceId) ||
errorsByWorkspaceId[workspaceId]
) {
return;
}
const workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`;
const workspaceClient = createClient(
workspaceOpencodeBaseUrl,
workspace.path?.trim() || undefined,
{ token, mode: "openwork" },
);
const session = unwrap(
await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }),
);
navigate(`/session/${session.id}`);
}, [baseUrl, navigate, token, workspaces]);
try {
const session = unwrap(
await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }),
);
navigate(`/session/${session.id}`);
} catch (error) {
const message = describeRouteError(error);
setRouteError(message);
if (isTransientStartupError(message)) {
setRetryingWorkspaceIds((current) => Array.from(new Set([...current, workspaceId])));
if (startupRetryTimerRef.current === null) {
startupRetryTimerRef.current = window.setTimeout(() => {
startupRetryTimerRef.current = null;
refreshInFlightRef.current = false;
void refreshRouteState();
}, 1_000);
}
}
}
}, [baseUrl, errorsByWorkspaceId, loading, navigate, refreshRouteState, retryingWorkspaceIds, token, workspaces]);
// Global shortcuts:
// Cmd/Ctrl+N -> new task in selected workspace
@@ -1165,7 +1396,7 @@ export function SessionRoute() {
const key = event.key?.toLowerCase();
if (key === "n" && !inEditable) {
event.preventDefault();
if (selectedWorkspaceId) {
if (canCreateTask && selectedWorkspaceId) {
void handleCreateTaskInWorkspace(selectedWorkspaceId);
}
return;
@@ -1177,7 +1408,7 @@ export function SessionRoute() {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleCreateTaskInWorkspace, selectedWorkspaceId]);
}, [canCreateTask, handleCreateTaskInWorkspace, selectedWorkspaceId]);
const paletteSessionOptions = useMemo<PaletteSessionOption[]>(() => {
const out: PaletteSessionOption[] = [];
@@ -1290,7 +1521,7 @@ export function SessionRoute() {
return (
<>
{selectedWorkspaceId && opencodeBaseUrl && token ? (
{opencodeClient && selectedWorkspaceId && opencodeBaseUrl && token ? (
<ReactSessionRuntime
workspaceId={selectedWorkspaceId}
sessionId={selectedSessionId}
@@ -1310,14 +1541,14 @@ export function SessionRoute() {
selectedWorkspaceRoot={selectedWorkspaceRoot}
runtimeWorkspaceId={selectedWorkspaceId || null}
workspaces={workspaces}
clientConnected={Boolean(opencodeClient)}
clientConnected={canCreateTask}
openworkServerStatus={client ? "connected" : "disconnected"}
openworkServerClient={client}
openworkServerToken={token}
developerMode={false}
headerStatus={client ? t("status.connected") : t("status.disconnected_label")}
busyHint={loading ? t("session.loading_detail") : null}
startupPhase={loading ? "nativeInit" : "ready"}
headerStatus={canCreateTask ? t("status.connected") : t("session.loading_detail")}
busyHint={effectiveLoading ? t("session.loading_detail") : null}
startupPhase={effectiveLoading ? "nativeInit" : "ready"}
providerConnectedIds={providerConnectedIds}
providers={providers}
mcpConnectedCount={0}
@@ -1338,9 +1569,9 @@ export function SessionRoute() {
sessionStatusById: {},
connectingWorkspaceId: null,
workspaceConnectionStateById: {},
newTaskDisabled: !Boolean(opencodeClient),
sidebarHydratedFromCache: false,
startupPhase: loading ? "nativeInit" : "ready",
newTaskDisabled: !canCreateTask,
sidebarHydratedFromCache: Object.values(sessionsByWorkspaceId).some((list) => list.length > 0),
startupPhase: effectiveLoading ? "nativeInit" : "ready",
onSelectWorkspace: async (workspaceId) => {
if (workspaceId === selectedWorkspaceId) return true;
setSelectedWorkspaceId(workspaceId);
@@ -1371,7 +1602,9 @@ export function SessionRoute() {
},
onPrefetchSession: () => {},
onCreateTaskInWorkspace: async (workspaceId) => {
const workspace = workspaces.find((item) => item.id === workspaceId);
void handleCreateTaskInWorkspace(workspaceId);
return;
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(
@@ -1415,7 +1648,7 @@ export function SessionRoute() {
onRedo: () => {},
}}
todos={[] satisfies TodoItem[]}
sessionLoadingById={(sessionId) => loading && Boolean(sessionId && sessionId === selectedSessionId)}
sessionLoadingById={(sessionId) => effectiveLoading && Boolean(sessionId && sessionId === selectedSessionId)}
shareWorkspaceModal={
shareWorkspaceState.shareWorkspaceOpen
? {
@@ -1509,6 +1742,13 @@ export function SessionRoute() {
}
: undefined
}
statusBar={effectiveLoading ? {
statusLabel: "Preparing workspace",
statusDetail: t("session.loading_detail"),
statusDotClass: "bg-amber-9",
statusPingClass: "bg-amber-9/35 animate-ping",
statusPulse: true,
} : undefined}
/>
<CreateWorkspaceModal
open={createWorkspaceOpen}

View File

@@ -21,7 +21,6 @@ import { createOpenworkServerStore, useOpenworkServerStoreSnapshot } from "../do
import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "../domains/connections/provider-auth/store";
import ProviderAuthModal from "../domains/connections/provider-auth/provider-auth-modal";
import ConnectionsModals from "../domains/connections/modals";
import { ReloadWorkspaceToast } from "../domains/shell-feedback/reload-workspace-toast";
import { GeneralSettingsView } from "../domains/settings/pages/general-view";
import { AdvancedView } from "../domains/settings/pages/advanced-view";
import { AppearanceView } from "../domains/settings/pages/appearance-view";
@@ -39,7 +38,6 @@ import { SettingsShell } from "../domains/settings/shell/settings-shell";
import { createAutomationsStore, useAutomationsStoreSnapshot } from "../domains/settings/state/automations-store";
import { createExtensionsStore, useExtensionsStoreSnapshot } from "../domains/settings/state/extensions-store";
import { usePlatform } from "../kernel/platform";
import { useSystemState } from "../kernel/system-state";
import { useLocal } from "../kernel/local-provider";
import {
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
@@ -68,6 +66,7 @@ import type { ModelOption, ModelRef } from "../../app/types";
import { recordInspectorEvent } from "./app-inspector";
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
import { abortSessionSafe } from "../../app/lib/opencode-session";
import { useReloadCoordinator } from "./reload-coordinator";
type RouteWorkspace = OpenworkWorkspaceInfo & {
displayNameResolved: string;
@@ -139,6 +138,35 @@ function mergeRouteWorkspaces(
return [...mergedServer, ...missingDesktop];
}
function reconcileSelectedWorkspaceId(
currentId: string,
serverList: { activeId?: string | null },
desktopList: Awaited<ReturnType<typeof workspaceBootstrap>> | null,
workspaces: RouteWorkspace[],
) {
const current = currentId.trim();
const serverIds = new Set(workspaces.map((workspace) => workspace.id));
if (current && serverIds.has(current)) return current;
const desktopSelectedId = resolveWorkspaceListSelectedId(desktopList);
const desktopSelected = desktopSelectedId
? desktopList?.workspaces?.find((workspace) => workspace.id === desktopSelectedId)
: null;
const currentDesktop = current
? desktopList?.workspaces?.find((workspace) => workspace.id === current)
: null;
const selectedPath = normalizeDirectoryPath((currentDesktop ?? desktopSelected)?.path ?? "");
if (selectedPath) {
const pathMatch = workspaces.find(
(workspace) => normalizeDirectoryPath(workspace.path ?? "") === selectedPath,
);
if (pathMatch) return pathMatch.id;
}
return serverList.activeId?.trim() || desktopSelectedId || workspaces[0]?.id || "";
}
function folderNameFromPath(path: string) {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
@@ -287,6 +315,7 @@ export function SettingsRoute() {
const local = useLocal();
const platform = usePlatform();
const checkDesktopRestriction = useCheckDesktopRestriction();
const reloadCoordinator = useReloadCoordinator();
const route = parseSettingsPath(location.pathname);
const [loading, setLoading] = useState(true);
@@ -419,21 +448,25 @@ export function SettingsRoute() {
return true;
}, [openworkClient, selectedWorkspaceId]);
const systemState = useSystemState({
hasActiveRuns: () => activeReloadBlockingSessions.length > 0,
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)),
setError: setRouteError,
});
const forceStopActiveSessionsAndReload = useCallback(async () => {
if (activeClient) {
for (const session of activeReloadBlockingSessions) {
await abortSessionSafe(activeClient, session.id).catch(() => undefined);
}
}
await systemState.reloadWorkspaceEngine();
}, [activeClient, activeReloadBlockingSessions, systemState.reloadWorkspaceEngine]);
useEffect(() => {
return reloadCoordinator.registerWorkspaceReloadControls({
canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)),
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
activeSessions: () => activeReloadBlockingSessions,
stopSession: async (sessionId) => {
if (!activeClient) return;
await abortSessionSafe(activeClient, sessionId);
},
});
}, [
activeClient,
activeReloadBlockingSessions,
openworkClient,
reloadCoordinator,
reloadWorkspaceEngineFromUi,
selectedWorkspace?.id,
selectedWorkspaceId,
]);
const shellLayout = useWorkspaceShellLayout({
expandedRightWidth: 320,
@@ -474,9 +507,9 @@ export function SettingsRoute() {
openworkServer: openworkServerStore,
runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId,
developerMode: () => routeStateRef.current.developerMode,
markReloadRequired: systemState.markReloadRequired,
markReloadRequired: reloadCoordinator.markReloadRequired,
}),
[openworkServerStore, systemState.markReloadRequired],
[openworkServerStore, reloadCoordinator.markReloadRequired],
);
const providerAuthStore = useMemo(
() =>
@@ -496,14 +529,14 @@ export function SettingsRoute() {
setDisabledProviders,
markOpencodeConfigReloadRequired: () => {
setConfigActionStatus(t("settings.config_updated"));
systemState.markReloadRequired("config", {
reloadCoordinator.markReloadRequired("config", {
type: "config",
name: "opencode.json",
action: "updated",
});
},
}),
[openworkServerStore, systemState.markReloadRequired],
[openworkServerStore, reloadCoordinator.markReloadRequired],
);
const extensionsStore = useMemo(
() =>
@@ -519,9 +552,9 @@ export function SettingsRoute() {
setBusyLabel,
setBusyStartedAt: () => {},
setError: setRouteError,
markReloadRequired: systemState.markReloadRequired,
markReloadRequired: reloadCoordinator.markReloadRequired,
}),
[openworkServerStore, systemState.markReloadRequired],
[openworkServerStore, reloadCoordinator.markReloadRequired],
);
const automationsStore = useMemo(
() =>
@@ -710,7 +743,9 @@ export function SettingsRoute() {
setWorkspaces(nextWorkspaces);
setSessionsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.sessions])));
setErrorsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.error])));
setSelectedWorkspaceId((current) => current || resolveWorkspaceListSelectedId(desktopList) || list.activeId?.trim() || nextWorkspaces[0]?.id || "");
setSelectedWorkspaceId((current) =>
reconcileSelectedWorkspaceId(current, list, desktopList, nextWorkspaces),
);
} catch (error) {
const message = describeRouteError(error);
console.error("[settings-route] refreshRouteState failed", error);
@@ -1004,9 +1039,9 @@ export function SettingsRoute() {
addPlugin={async () => {
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
}}
reloadWorkspaceEngine={systemState.reloadWorkspaceEngine}
reloadBusy={systemState.reload.reloadBusy}
canReloadWorkspace={systemState.canReloadWorkspaceEngine}
reloadWorkspaceEngine={reloadCoordinator.reloadWorkspaceEngine}
reloadBusy={false}
canReloadWorkspace={reloadCoordinator.canReloadWorkspaceEngine}
openLink={(url) => platform.openLink(url)}
/>
);
@@ -1130,10 +1165,10 @@ export function SettingsRoute() {
updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings,
resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings,
testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection,
canReloadWorkspace: systemState.canReloadWorkspaceEngine,
reloadWorkspaceEngine: systemState.reloadWorkspaceEngine,
reloadBusy: systemState.reload.reloadBusy,
reloadError: systemState.reload.reloadError ?? routeError,
canReloadWorkspace: reloadCoordinator.canReloadWorkspaceEngine,
reloadWorkspaceEngine: reloadCoordinator.reloadWorkspaceEngine,
reloadBusy: false,
reloadError: routeError,
developerMode,
}}
/>
@@ -1299,32 +1334,6 @@ export function SettingsRoute() {
remoteSubmitting={createWorkspaceRemoteBusy}
remoteError={createWorkspaceRemoteError}
/>
<div className="pointer-events-none fixed right-4 top-4 z-50 w-[min(24rem,calc(100vw-1.5rem))] max-w-full sm:right-6 sm:top-6">
<div className="pointer-events-auto">
<ReloadWorkspaceToast
open={systemState.reload.reloadPending}
title={systemState.reloadCopy.title}
description={systemState.reloadCopy.body}
trigger={systemState.reload.reloadTrigger}
error={systemState.reload.reloadError}
reloadLabel={
activeReloadBlockingSessions.length > 0
? t("app.reload_stop_tasks")
: t("app.reload_now")
}
dismissLabel={t("app.reload_later")}
busy={systemState.reload.reloadBusy}
canReload={systemState.canReloadWorkspaceEngine}
hasActiveRuns={activeReloadBlockingSessions.length > 0}
onReload={() => {
void (activeReloadBlockingSessions.length > 0
? forceStopActiveSessionsAndReload()
: systemState.reloadWorkspaceEngine());
}}
onDismiss={systemState.clearReloadRequired}
/>
</div>
</div>
<ConnectionsModals
client={activeClient}
projectDir={selectedWorkspaceRoot}
@@ -1336,7 +1345,7 @@ export function SettingsRoute() {
if (!activeClient) return undefined;
return abortSessionSafe(activeClient, sessionId);
}}
onReloadEngine={systemState.reloadWorkspaceEngine}
onReloadEngine={reloadCoordinator.reloadWorkspaceEngine}
modalState={{
mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen,
mcpAuthEntry: connectionsSnapshot.mcpAuthEntry,

View File

@@ -486,23 +486,30 @@ async function fetchOpencodeJson(
const url = new URL(baseUrl);
url.pathname = path.startsWith("/") ? path : `/${path}`;
const directory = resolveOpencodeDirectory(workspace);
if (init.query instanceof URLSearchParams) {
url.search = init.query.toString();
const params = new URLSearchParams(init.query);
if (directory && !params.has("directory")) {
params.set("directory", directory);
}
url.search = params.toString();
} else if (init.query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(init.query)) {
if (value === undefined || value === null) continue;
params.set(key, String(value));
}
if (directory && !params.has("directory")) {
params.set("directory", directory);
}
url.search = params.toString();
} else {
url.search = "";
url.search = directory ? new URLSearchParams({ directory }).toString() : "";
}
const headers = new Headers();
headers.set("Content-Type", "application/json");
const directory = resolveOpencodeDirectory(workspace);
if (directory) {
headers.set("x-opencode-directory", directory);
}