mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix React startup and reload flows
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
usesChromeDevtoolsAutoConnect,
|
usesChromeDevtoolsAutoConnect,
|
||||||
validateMcpServerName,
|
validateMcpServerName,
|
||||||
} from "../../../app/mcp";
|
} from "../../../app/mcp";
|
||||||
|
import { buildOpenworkWorkspaceBaseUrl } from "../../../app/lib/openwork-server";
|
||||||
import type {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
McpServerEntry,
|
McpServerEntry,
|
||||||
@@ -171,7 +172,9 @@ export function createConnectionsStore(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeClient = createClient(`${openworkBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, {
|
const mountedBaseUrl =
|
||||||
|
buildOpenworkWorkspaceBaseUrl(openworkBaseUrl, options.runtimeWorkspaceId()) ?? openworkBaseUrl;
|
||||||
|
activeClient = createClient(`${mountedBaseUrl.replace(/\/+$/, "")}/opencode`, undefined, {
|
||||||
token,
|
token,
|
||||||
mode: "openwork",
|
mode: "openwork",
|
||||||
});
|
});
|
||||||
@@ -530,30 +533,40 @@ export function createConnectionsStore(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpAddConfig =
|
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
|
||||||
entryType === "remote"
|
// 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
|
||||||
type: "remote" as const,
|
// hot-add endpoint here: when the SDK client is rooted at the aggregate
|
||||||
url: entry.url!,
|
// `/opencode` route it can resolve to an internal `local_*` workspace
|
||||||
enabled: true,
|
// id that the OpenWork server does not expose, producing a confusing
|
||||||
...(entry.oauth ? { oauth: {} } : {}),
|
// `workspace_not_found` after the config write already succeeded.
|
||||||
}
|
setStateField("mcpStatuses", filterConfiguredStatuses(snapshot.mcpStatuses, snapshot.mcpServers));
|
||||||
: {
|
} else {
|
||||||
type: "local" as const,
|
const mcpAddConfig =
|
||||||
command: entry.command!,
|
entryType === "remote"
|
||||||
enabled: true,
|
? {
|
||||||
...(mcpEnvironment ? { environment: mcpEnvironment } : {}),
|
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(
|
const status = unwrap(
|
||||||
await activeClient.mcp.add({
|
await activeClient.mcp.add({
|
||||||
directory: resolvedProjectDir,
|
directory: resolvedProjectDir,
|
||||||
name: slug,
|
name: slug,
|
||||||
config: mcpAddConfig,
|
config: mcpAddConfig,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
setStateField("mcpStatuses", status as McpStatusMap);
|
setStateField("mcpStatuses", status as McpStatusMap);
|
||||||
|
}
|
||||||
options.markReloadRequired?.("mcp", { type: "mcp", name: slug, action });
|
options.markReloadRequired?.("mcp", { type: "mcp", name: slug, action });
|
||||||
await refreshMcpServers();
|
await refreshMcpServers();
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ export function ServerProvider({ children, defaultUrl }: ServerProviderProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
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);
|
setHealthy(undefined);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const PHASE_MESSAGES: Record<BootPhaseId, string> = {
|
|||||||
idle: "",
|
idle: "",
|
||||||
"bootstrapping-workspaces": "Loading your workspaces",
|
"bootstrapping-workspaces": "Loading your workspaces",
|
||||||
"starting-openwork-server": "Starting the OpenWork server",
|
"starting-openwork-server": "Starting the OpenWork server",
|
||||||
"starting-engine": "Starting OpenCode",
|
"starting-engine": "Preparing workspace",
|
||||||
"activating-workspace": "Activating your workspace",
|
"activating-workspace": "Activating your workspace",
|
||||||
ready: "Ready",
|
ready: "Ready",
|
||||||
error: "Something went wrong",
|
error: "Something went wrong",
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function useDesktopRuntimeBoot() {
|
|||||||
if (!workspacePaths.includes(path)) workspacePaths.push(path);
|
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, {
|
const engineStartResult = await engineStart(workspaceRoot, {
|
||||||
runtime: "openwork-orchestrator",
|
runtime: "openwork-orchestrator",
|
||||||
workspacePaths,
|
workspacePaths,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { BootStateProvider } from "./boot-state";
|
|||||||
import { DesktopRuntimeBoot } from "./desktop-runtime-boot";
|
import { DesktopRuntimeBoot } from "./desktop-runtime-boot";
|
||||||
import { startDebugLogger, stopDebugLogger } from "./debug-logger";
|
import { startDebugLogger, stopDebugLogger } from "./debug-logger";
|
||||||
import { MigrationPrompt } from "./migration-prompt";
|
import { MigrationPrompt } from "./migration-prompt";
|
||||||
|
import { ReloadCoordinatorProvider } from "./reload-coordinator";
|
||||||
|
|
||||||
function resolveDefaultServerUrl(): string {
|
function resolveDefaultServerUrl(): string {
|
||||||
if (isDesktopRuntime()) return "http://127.0.0.1:4096";
|
if (isDesktopRuntime()) return "http://127.0.0.1:4096";
|
||||||
@@ -63,7 +64,9 @@ export function AppProviders({ children }: AppProvidersProps) {
|
|||||||
<DenAuthProvider>
|
<DenAuthProvider>
|
||||||
<DesktopConfigProvider>
|
<DesktopConfigProvider>
|
||||||
<RestrictionNoticeProvider>
|
<RestrictionNoticeProvider>
|
||||||
<LocalProvider>{children}</LocalProvider>
|
<LocalProvider>
|
||||||
|
<ReloadCoordinatorProvider>{children}</ReloadCoordinatorProvider>
|
||||||
|
</LocalProvider>
|
||||||
</RestrictionNoticeProvider>
|
</RestrictionNoticeProvider>
|
||||||
</DesktopConfigProvider>
|
</DesktopConfigProvider>
|
||||||
</DenAuthProvider>
|
</DenAuthProvider>
|
||||||
|
|||||||
149
apps/app/src/react-app/shell/reload-coordinator.tsx
Normal file
149
apps/app/src/react-app/shell/reload-coordinator.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ import { useReactRenderWatchdog } from "./react-render-watchdog";
|
|||||||
import { getModelBehaviorSummary } from "../../app/lib/model-behavior";
|
import { getModelBehaviorSummary } from "../../app/lib/model-behavior";
|
||||||
import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers";
|
import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers";
|
||||||
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
||||||
|
import { useReloadCoordinator } from "./reload-coordinator";
|
||||||
|
|
||||||
type RouteWorkspace = OpenworkWorkspaceInfo & {
|
type RouteWorkspace = OpenworkWorkspaceInfo & {
|
||||||
displayNameResolved: string;
|
displayNameResolved: string;
|
||||||
@@ -133,6 +134,44 @@ async function resolveRouteOpenworkConnection() {
|
|||||||
return { normalizedBaseUrl, resolvedToken, hostInfo };
|
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) {
|
function workspaceLabel(workspace: OpenworkWorkspaceInfo) {
|
||||||
return (
|
return (
|
||||||
workspace.displayName?.trim() ||
|
workspace.displayName?.trim() ||
|
||||||
@@ -202,11 +241,16 @@ function toSessionGroups(
|
|||||||
workspaces: RouteWorkspace[],
|
workspaces: RouteWorkspace[],
|
||||||
sessionsByWorkspaceId: Record<string, any[]>,
|
sessionsByWorkspaceId: Record<string, any[]>,
|
||||||
errorsByWorkspaceId: Record<string, string | null>,
|
errorsByWorkspaceId: Record<string, string | null>,
|
||||||
|
loadingWorkspaceIds: Set<string>,
|
||||||
): WorkspaceSessionGroup[] {
|
): WorkspaceSessionGroup[] {
|
||||||
return workspaces.map((workspace) => ({
|
return workspaces.map((workspace) => ({
|
||||||
workspace,
|
workspace,
|
||||||
sessions: (sessionsByWorkspaceId[workspace.id] ?? []) as WorkspaceSessionGroup["sessions"],
|
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],
|
error: errorsByWorkspaceId[workspace.id],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -280,6 +324,7 @@ export function SessionRoute() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const local = useLocal();
|
const local = useLocal();
|
||||||
|
const reloadCoordinator = useReloadCoordinator();
|
||||||
const checkDesktopRestriction = useCheckDesktopRestriction();
|
const checkDesktopRestriction = useCheckDesktopRestriction();
|
||||||
const restrictionNotice = useRestrictionNotice();
|
const restrictionNotice = useRestrictionNotice();
|
||||||
const params = useParams<{ sessionId?: string }>();
|
const params = useParams<{ sessionId?: string }>();
|
||||||
@@ -299,7 +344,11 @@ export function SessionRoute() {
|
|||||||
// One-way latch for "a refreshRouteState is currently running"; prevents
|
// One-way latch for "a refreshRouteState is currently running"; prevents
|
||||||
// overlapping route refreshes from queueing up when the user clicks fast.
|
// overlapping route refreshes from queueing up when the user clicks fast.
|
||||||
const refreshInFlightRef = useRef(false);
|
const refreshInFlightRef = useRef(false);
|
||||||
|
const reloadEventCursorByWorkspaceRef = useRef<Record<string, number | null>>({});
|
||||||
const workspacesRef = useRef<RouteWorkspace[]>([]);
|
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 [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false);
|
||||||
const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false);
|
const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false);
|
||||||
const [createWorkspaceRemoteBusy, setCreateWorkspaceRemoteBusy] = useState(false);
|
const [createWorkspaceRemoteBusy, setCreateWorkspaceRemoteBusy] = useState(false);
|
||||||
@@ -362,6 +411,7 @@ export function SessionRoute() {
|
|||||||
setRouteError(null);
|
setRouteError(null);
|
||||||
let desktopList = null as Awaited<ReturnType<typeof workspaceBootstrap>> | null;
|
let desktopList = null as Awaited<ReturnType<typeof workspaceBootstrap>> | null;
|
||||||
let desktopWorkspaces = workspacesRef.current;
|
let desktopWorkspaces = workspacesRef.current;
|
||||||
|
let routeReadyAfterRefresh = true;
|
||||||
try {
|
try {
|
||||||
if (isDesktopRuntime()) {
|
if (isDesktopRuntime()) {
|
||||||
try {
|
try {
|
||||||
@@ -402,6 +452,27 @@ export function SessionRoute() {
|
|||||||
const sessionEntries = await Promise.all(
|
const sessionEntries = await Promise.all(
|
||||||
nextWorkspaces.map(async (workspace) => {
|
nextWorkspaces.map(async (workspace) => {
|
||||||
try {
|
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 });
|
const response = await openworkClient.listSessions(workspace.id, { limit: 200 });
|
||||||
// The underlying opencode instance is shared across all local
|
// The underlying opencode instance is shared across all local
|
||||||
// workspaces attached to the same openwork-server, so `listSessions`
|
// workspaces attached to the same openwork-server, so `listSessions`
|
||||||
@@ -414,16 +485,41 @@ export function SessionRoute() {
|
|||||||
normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot,
|
normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot,
|
||||||
)
|
)
|
||||||
: (response.items ?? []);
|
: (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) {
|
} 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 {
|
return {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
sessions: [],
|
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),
|
// Prefer, in order: the URL-selected workspace (if it owns the session),
|
||||||
// the user's last-active workspace from localStorage, the desktop's
|
// 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
|
// Tell the boot overlay the first route data load has completed so
|
||||||
// the overlay dismisses after BOTH the desktop boot and the workspace
|
// the overlay dismisses after BOTH the desktop boot and the workspace
|
||||||
// list/sessions are ready.
|
// list/sessions are ready.
|
||||||
markBootRouteReady();
|
if (routeReadyAfterRefresh) {
|
||||||
|
markBootRouteReady();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [markBootRouteReady, selectedSessionId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
workspacesRef.current = workspaces;
|
workspacesRef.current = workspaces;
|
||||||
}, [workspaces]);
|
}, [workspaces]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionsByWorkspaceIdRef.current = sessionsByWorkspaceId;
|
||||||
|
}, [sessionsByWorkspaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -522,6 +716,10 @@ export function SessionRoute() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (startupRetryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(startupRetryTimerRef.current);
|
||||||
|
startupRetryTimerRef.current = null;
|
||||||
|
}
|
||||||
window.removeEventListener("openwork-server-settings-changed", handleSettingsChange);
|
window.removeEventListener("openwork-server-settings-changed", handleSettingsChange);
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.removeEventListener("visibilitychange", handleVisibility);
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
@@ -551,6 +749,7 @@ export function SessionRoute() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dispose = publishInspectorSlice("route", () => ({
|
const dispose = publishInspectorSlice("route", () => ({
|
||||||
loading,
|
loading,
|
||||||
|
retryingWorkspaceIds,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
tokenPresent: token.length > 0,
|
tokenPresent: token.length > 0,
|
||||||
connected: Boolean(client),
|
connected: Boolean(client),
|
||||||
@@ -564,6 +763,7 @@ export function SessionRoute() {
|
|||||||
workspaceType: workspace.workspaceType,
|
workspaceType: workspace.workspaceType,
|
||||||
path: workspace.path,
|
path: workspace.path,
|
||||||
sessionCount: (sessionsByWorkspaceId[workspace.id] ?? []).length,
|
sessionCount: (sessionsByWorkspaceId[workspace.id] ?? []).length,
|
||||||
|
loading: retryingWorkspaceIds.includes(workspace.id),
|
||||||
error: errorsByWorkspaceId[workspace.id] ?? null,
|
error: errorsByWorkspaceId[workspace.id] ?? null,
|
||||||
})),
|
})),
|
||||||
sessionsByWorkspaceId: Object.fromEntries(
|
sessionsByWorkspaceId: Object.fromEntries(
|
||||||
@@ -583,6 +783,7 @@ export function SessionRoute() {
|
|||||||
client,
|
client,
|
||||||
errorsByWorkspaceId,
|
errorsByWorkspaceId,
|
||||||
loading,
|
loading,
|
||||||
|
retryingWorkspaceIds,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
selectedWorkspaceId,
|
selectedWorkspaceId,
|
||||||
routeError,
|
routeError,
|
||||||
@@ -613,8 +814,8 @@ export function SessionRoute() {
|
|||||||
// the onboarding flow, not from the route effect loop.
|
// the onboarding flow, not from the route effect loop.
|
||||||
|
|
||||||
const workspaceSessionGroups = useMemo(
|
const workspaceSessionGroups = useMemo(
|
||||||
() => toSessionGroups(workspaces, sessionsByWorkspaceId, errorsByWorkspaceId),
|
() => toSessionGroups(workspaces, sessionsByWorkspaceId, errorsByWorkspaceId, new Set(retryingWorkspaceIds)),
|
||||||
[errorsByWorkspaceId, sessionsByWorkspaceId, workspaces],
|
[errorsByWorkspaceId, retryingWorkspaceIds, sessionsByWorkspaceId, workspaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedWorkspace = useMemo(
|
const selectedWorkspace = useMemo(
|
||||||
@@ -650,16 +851,22 @@ export function SessionRoute() {
|
|||||||
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, selectedWorkspaceId) ?? baseUrl;
|
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, selectedWorkspaceId) ?? baseUrl;
|
||||||
return `${mounted.replace(/\/+$|\/+$/g, "")}/opencode`;
|
return `${mounted.replace(/\/+$|\/+$/g, "")}/opencode`;
|
||||||
}, [baseUrl, selectedWorkspaceId]);
|
}, [baseUrl, selectedWorkspaceId]);
|
||||||
|
const selectedWorkspaceIsLoading = retryingWorkspaceIds.includes(selectedWorkspaceId);
|
||||||
|
const selectedWorkspaceError = errorsByWorkspaceId[selectedWorkspaceId] ?? null;
|
||||||
|
const effectiveLoading = loading || selectedWorkspaceIsLoading;
|
||||||
|
|
||||||
const opencodeClient = useMemo(
|
const opencodeClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
opencodeBaseUrl && token
|
opencodeBaseUrl && token && !effectiveLoading && !selectedWorkspaceError
|
||||||
? createClient(opencodeBaseUrl, selectedWorkspaceRoot || undefined, {
|
? createClient(opencodeBaseUrl, selectedWorkspaceRoot || undefined, {
|
||||||
token,
|
token,
|
||||||
mode: "openwork",
|
mode: "openwork",
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
[opencodeBaseUrl, selectedWorkspaceRoot, token],
|
[effectiveLoading, opencodeBaseUrl, selectedWorkspaceError, selectedWorkspaceRoot, token],
|
||||||
|
);
|
||||||
|
const canCreateTask = Boolean(
|
||||||
|
opencodeClient && selectedWorkspaceId && !effectiveLoading && !selectedWorkspaceError,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1132,18 +1339,42 @@ export function SessionRoute() {
|
|||||||
|
|
||||||
const handleCreateTaskInWorkspace = useCallback(async (workspaceId: string) => {
|
const handleCreateTaskInWorkspace = useCallback(async (workspaceId: string) => {
|
||||||
const workspace = workspaces.find((item) => item.id === workspaceId);
|
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 workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`;
|
||||||
const workspaceClient = createClient(
|
const workspaceClient = createClient(
|
||||||
workspaceOpencodeBaseUrl,
|
workspaceOpencodeBaseUrl,
|
||||||
workspace.path?.trim() || undefined,
|
workspace.path?.trim() || undefined,
|
||||||
{ token, mode: "openwork" },
|
{ token, mode: "openwork" },
|
||||||
);
|
);
|
||||||
const session = unwrap(
|
try {
|
||||||
await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }),
|
const session = unwrap(
|
||||||
);
|
await workspaceClient.session.create({ directory: workspace.path?.trim() || undefined }),
|
||||||
navigate(`/session/${session.id}`);
|
);
|
||||||
}, [baseUrl, navigate, token, workspaces]);
|
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:
|
// Global shortcuts:
|
||||||
// Cmd/Ctrl+N -> new task in selected workspace
|
// Cmd/Ctrl+N -> new task in selected workspace
|
||||||
@@ -1165,7 +1396,7 @@ export function SessionRoute() {
|
|||||||
const key = event.key?.toLowerCase();
|
const key = event.key?.toLowerCase();
|
||||||
if (key === "n" && !inEditable) {
|
if (key === "n" && !inEditable) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedWorkspaceId) {
|
if (canCreateTask && selectedWorkspaceId) {
|
||||||
void handleCreateTaskInWorkspace(selectedWorkspaceId);
|
void handleCreateTaskInWorkspace(selectedWorkspaceId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1177,7 +1408,7 @@ export function SessionRoute() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [handleCreateTaskInWorkspace, selectedWorkspaceId]);
|
}, [canCreateTask, handleCreateTaskInWorkspace, selectedWorkspaceId]);
|
||||||
|
|
||||||
const paletteSessionOptions = useMemo<PaletteSessionOption[]>(() => {
|
const paletteSessionOptions = useMemo<PaletteSessionOption[]>(() => {
|
||||||
const out: PaletteSessionOption[] = [];
|
const out: PaletteSessionOption[] = [];
|
||||||
@@ -1290,7 +1521,7 @@ export function SessionRoute() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedWorkspaceId && opencodeBaseUrl && token ? (
|
{opencodeClient && selectedWorkspaceId && opencodeBaseUrl && token ? (
|
||||||
<ReactSessionRuntime
|
<ReactSessionRuntime
|
||||||
workspaceId={selectedWorkspaceId}
|
workspaceId={selectedWorkspaceId}
|
||||||
sessionId={selectedSessionId}
|
sessionId={selectedSessionId}
|
||||||
@@ -1310,14 +1541,14 @@ export function SessionRoute() {
|
|||||||
selectedWorkspaceRoot={selectedWorkspaceRoot}
|
selectedWorkspaceRoot={selectedWorkspaceRoot}
|
||||||
runtimeWorkspaceId={selectedWorkspaceId || null}
|
runtimeWorkspaceId={selectedWorkspaceId || null}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
clientConnected={Boolean(opencodeClient)}
|
clientConnected={canCreateTask}
|
||||||
openworkServerStatus={client ? "connected" : "disconnected"}
|
openworkServerStatus={client ? "connected" : "disconnected"}
|
||||||
openworkServerClient={client}
|
openworkServerClient={client}
|
||||||
openworkServerToken={token}
|
openworkServerToken={token}
|
||||||
developerMode={false}
|
developerMode={false}
|
||||||
headerStatus={client ? t("status.connected") : t("status.disconnected_label")}
|
headerStatus={canCreateTask ? t("status.connected") : t("session.loading_detail")}
|
||||||
busyHint={loading ? t("session.loading_detail") : null}
|
busyHint={effectiveLoading ? t("session.loading_detail") : null}
|
||||||
startupPhase={loading ? "nativeInit" : "ready"}
|
startupPhase={effectiveLoading ? "nativeInit" : "ready"}
|
||||||
providerConnectedIds={providerConnectedIds}
|
providerConnectedIds={providerConnectedIds}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
mcpConnectedCount={0}
|
mcpConnectedCount={0}
|
||||||
@@ -1338,9 +1569,9 @@ export function SessionRoute() {
|
|||||||
sessionStatusById: {},
|
sessionStatusById: {},
|
||||||
connectingWorkspaceId: null,
|
connectingWorkspaceId: null,
|
||||||
workspaceConnectionStateById: {},
|
workspaceConnectionStateById: {},
|
||||||
newTaskDisabled: !Boolean(opencodeClient),
|
newTaskDisabled: !canCreateTask,
|
||||||
sidebarHydratedFromCache: false,
|
sidebarHydratedFromCache: Object.values(sessionsByWorkspaceId).some((list) => list.length > 0),
|
||||||
startupPhase: loading ? "nativeInit" : "ready",
|
startupPhase: effectiveLoading ? "nativeInit" : "ready",
|
||||||
onSelectWorkspace: async (workspaceId) => {
|
onSelectWorkspace: async (workspaceId) => {
|
||||||
if (workspaceId === selectedWorkspaceId) return true;
|
if (workspaceId === selectedWorkspaceId) return true;
|
||||||
setSelectedWorkspaceId(workspaceId);
|
setSelectedWorkspaceId(workspaceId);
|
||||||
@@ -1371,7 +1602,9 @@ export function SessionRoute() {
|
|||||||
},
|
},
|
||||||
onPrefetchSession: () => {},
|
onPrefetchSession: () => {},
|
||||||
onCreateTaskInWorkspace: async (workspaceId) => {
|
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;
|
if (!workspace || !token || !baseUrl) return;
|
||||||
const workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`;
|
const workspaceOpencodeBaseUrl = `${(buildOpenworkWorkspaceBaseUrl(baseUrl, workspace.id) ?? baseUrl).replace(/\/+$|\/+$/g, "")}/opencode`;
|
||||||
const workspaceClient = createClient(
|
const workspaceClient = createClient(
|
||||||
@@ -1415,7 +1648,7 @@ export function SessionRoute() {
|
|||||||
onRedo: () => {},
|
onRedo: () => {},
|
||||||
}}
|
}}
|
||||||
todos={[] satisfies TodoItem[]}
|
todos={[] satisfies TodoItem[]}
|
||||||
sessionLoadingById={(sessionId) => loading && Boolean(sessionId && sessionId === selectedSessionId)}
|
sessionLoadingById={(sessionId) => effectiveLoading && Boolean(sessionId && sessionId === selectedSessionId)}
|
||||||
shareWorkspaceModal={
|
shareWorkspaceModal={
|
||||||
shareWorkspaceState.shareWorkspaceOpen
|
shareWorkspaceState.shareWorkspaceOpen
|
||||||
? {
|
? {
|
||||||
@@ -1509,6 +1742,13 @@ export function SessionRoute() {
|
|||||||
}
|
}
|
||||||
: undefined
|
: 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
|
<CreateWorkspaceModal
|
||||||
open={createWorkspaceOpen}
|
open={createWorkspaceOpen}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { createOpenworkServerStore, useOpenworkServerStoreSnapshot } from "../do
|
|||||||
import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "../domains/connections/provider-auth/store";
|
import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "../domains/connections/provider-auth/store";
|
||||||
import ProviderAuthModal from "../domains/connections/provider-auth/provider-auth-modal";
|
import ProviderAuthModal from "../domains/connections/provider-auth/provider-auth-modal";
|
||||||
import ConnectionsModals from "../domains/connections/modals";
|
import ConnectionsModals from "../domains/connections/modals";
|
||||||
import { ReloadWorkspaceToast } from "../domains/shell-feedback/reload-workspace-toast";
|
|
||||||
import { GeneralSettingsView } from "../domains/settings/pages/general-view";
|
import { GeneralSettingsView } from "../domains/settings/pages/general-view";
|
||||||
import { AdvancedView } from "../domains/settings/pages/advanced-view";
|
import { AdvancedView } from "../domains/settings/pages/advanced-view";
|
||||||
import { AppearanceView } from "../domains/settings/pages/appearance-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 { createAutomationsStore, useAutomationsStoreSnapshot } from "../domains/settings/state/automations-store";
|
||||||
import { createExtensionsStore, useExtensionsStoreSnapshot } from "../domains/settings/state/extensions-store";
|
import { createExtensionsStore, useExtensionsStoreSnapshot } from "../domains/settings/state/extensions-store";
|
||||||
import { usePlatform } from "../kernel/platform";
|
import { usePlatform } from "../kernel/platform";
|
||||||
import { useSystemState } from "../kernel/system-state";
|
|
||||||
import { useLocal } from "../kernel/local-provider";
|
import { useLocal } from "../kernel/local-provider";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
|
||||||
@@ -68,6 +66,7 @@ import type { ModelOption, ModelRef } from "../../app/types";
|
|||||||
import { recordInspectorEvent } from "./app-inspector";
|
import { recordInspectorEvent } from "./app-inspector";
|
||||||
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
|
||||||
import { abortSessionSafe } from "../../app/lib/opencode-session";
|
import { abortSessionSafe } from "../../app/lib/opencode-session";
|
||||||
|
import { useReloadCoordinator } from "./reload-coordinator";
|
||||||
|
|
||||||
type RouteWorkspace = OpenworkWorkspaceInfo & {
|
type RouteWorkspace = OpenworkWorkspaceInfo & {
|
||||||
displayNameResolved: string;
|
displayNameResolved: string;
|
||||||
@@ -139,6 +138,35 @@ function mergeRouteWorkspaces(
|
|||||||
return [...mergedServer, ...missingDesktop];
|
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) {
|
function folderNameFromPath(path: string) {
|
||||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const parts = normalized.split("/").filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
@@ -287,6 +315,7 @@ export function SettingsRoute() {
|
|||||||
const local = useLocal();
|
const local = useLocal();
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const checkDesktopRestriction = useCheckDesktopRestriction();
|
const checkDesktopRestriction = useCheckDesktopRestriction();
|
||||||
|
const reloadCoordinator = useReloadCoordinator();
|
||||||
const route = parseSettingsPath(location.pathname);
|
const route = parseSettingsPath(location.pathname);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -419,21 +448,25 @@ export function SettingsRoute() {
|
|||||||
return true;
|
return true;
|
||||||
}, [openworkClient, selectedWorkspaceId]);
|
}, [openworkClient, selectedWorkspaceId]);
|
||||||
|
|
||||||
const systemState = useSystemState({
|
useEffect(() => {
|
||||||
hasActiveRuns: () => activeReloadBlockingSessions.length > 0,
|
return reloadCoordinator.registerWorkspaceReloadControls({
|
||||||
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
|
canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)),
|
||||||
canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)),
|
reloadWorkspaceEngine: reloadWorkspaceEngineFromUi,
|
||||||
setError: setRouteError,
|
activeSessions: () => activeReloadBlockingSessions,
|
||||||
});
|
stopSession: async (sessionId) => {
|
||||||
|
if (!activeClient) return;
|
||||||
const forceStopActiveSessionsAndReload = useCallback(async () => {
|
await abortSessionSafe(activeClient, sessionId);
|
||||||
if (activeClient) {
|
},
|
||||||
for (const session of activeReloadBlockingSessions) {
|
});
|
||||||
await abortSessionSafe(activeClient, session.id).catch(() => undefined);
|
}, [
|
||||||
}
|
activeClient,
|
||||||
}
|
activeReloadBlockingSessions,
|
||||||
await systemState.reloadWorkspaceEngine();
|
openworkClient,
|
||||||
}, [activeClient, activeReloadBlockingSessions, systemState.reloadWorkspaceEngine]);
|
reloadCoordinator,
|
||||||
|
reloadWorkspaceEngineFromUi,
|
||||||
|
selectedWorkspace?.id,
|
||||||
|
selectedWorkspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const shellLayout = useWorkspaceShellLayout({
|
const shellLayout = useWorkspaceShellLayout({
|
||||||
expandedRightWidth: 320,
|
expandedRightWidth: 320,
|
||||||
@@ -474,9 +507,9 @@ export function SettingsRoute() {
|
|||||||
openworkServer: openworkServerStore,
|
openworkServer: openworkServerStore,
|
||||||
runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId,
|
runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId,
|
||||||
developerMode: () => routeStateRef.current.developerMode,
|
developerMode: () => routeStateRef.current.developerMode,
|
||||||
markReloadRequired: systemState.markReloadRequired,
|
markReloadRequired: reloadCoordinator.markReloadRequired,
|
||||||
}),
|
}),
|
||||||
[openworkServerStore, systemState.markReloadRequired],
|
[openworkServerStore, reloadCoordinator.markReloadRequired],
|
||||||
);
|
);
|
||||||
const providerAuthStore = useMemo(
|
const providerAuthStore = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -496,14 +529,14 @@ export function SettingsRoute() {
|
|||||||
setDisabledProviders,
|
setDisabledProviders,
|
||||||
markOpencodeConfigReloadRequired: () => {
|
markOpencodeConfigReloadRequired: () => {
|
||||||
setConfigActionStatus(t("settings.config_updated"));
|
setConfigActionStatus(t("settings.config_updated"));
|
||||||
systemState.markReloadRequired("config", {
|
reloadCoordinator.markReloadRequired("config", {
|
||||||
type: "config",
|
type: "config",
|
||||||
name: "opencode.json",
|
name: "opencode.json",
|
||||||
action: "updated",
|
action: "updated",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[openworkServerStore, systemState.markReloadRequired],
|
[openworkServerStore, reloadCoordinator.markReloadRequired],
|
||||||
);
|
);
|
||||||
const extensionsStore = useMemo(
|
const extensionsStore = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -519,9 +552,9 @@ export function SettingsRoute() {
|
|||||||
setBusyLabel,
|
setBusyLabel,
|
||||||
setBusyStartedAt: () => {},
|
setBusyStartedAt: () => {},
|
||||||
setError: setRouteError,
|
setError: setRouteError,
|
||||||
markReloadRequired: systemState.markReloadRequired,
|
markReloadRequired: reloadCoordinator.markReloadRequired,
|
||||||
}),
|
}),
|
||||||
[openworkServerStore, systemState.markReloadRequired],
|
[openworkServerStore, reloadCoordinator.markReloadRequired],
|
||||||
);
|
);
|
||||||
const automationsStore = useMemo(
|
const automationsStore = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -710,7 +743,9 @@ export function SettingsRoute() {
|
|||||||
setWorkspaces(nextWorkspaces);
|
setWorkspaces(nextWorkspaces);
|
||||||
setSessionsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.sessions])));
|
setSessionsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.sessions])));
|
||||||
setErrorsByWorkspaceId(Object.fromEntries(sessionEntries.map((entry) => [entry.workspaceId, entry.error])));
|
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) {
|
} catch (error) {
|
||||||
const message = describeRouteError(error);
|
const message = describeRouteError(error);
|
||||||
console.error("[settings-route] refreshRouteState failed", error);
|
console.error("[settings-route] refreshRouteState failed", error);
|
||||||
@@ -1004,9 +1039,9 @@ export function SettingsRoute() {
|
|||||||
addPlugin={async () => {
|
addPlugin={async () => {
|
||||||
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
|
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
|
||||||
}}
|
}}
|
||||||
reloadWorkspaceEngine={systemState.reloadWorkspaceEngine}
|
reloadWorkspaceEngine={reloadCoordinator.reloadWorkspaceEngine}
|
||||||
reloadBusy={systemState.reload.reloadBusy}
|
reloadBusy={false}
|
||||||
canReloadWorkspace={systemState.canReloadWorkspaceEngine}
|
canReloadWorkspace={reloadCoordinator.canReloadWorkspaceEngine}
|
||||||
openLink={(url) => platform.openLink(url)}
|
openLink={(url) => platform.openLink(url)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1130,10 +1165,10 @@ export function SettingsRoute() {
|
|||||||
updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings,
|
updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings,
|
||||||
resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings,
|
resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings,
|
||||||
testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection,
|
testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection,
|
||||||
canReloadWorkspace: systemState.canReloadWorkspaceEngine,
|
canReloadWorkspace: reloadCoordinator.canReloadWorkspaceEngine,
|
||||||
reloadWorkspaceEngine: systemState.reloadWorkspaceEngine,
|
reloadWorkspaceEngine: reloadCoordinator.reloadWorkspaceEngine,
|
||||||
reloadBusy: systemState.reload.reloadBusy,
|
reloadBusy: false,
|
||||||
reloadError: systemState.reload.reloadError ?? routeError,
|
reloadError: routeError,
|
||||||
developerMode,
|
developerMode,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1299,32 +1334,6 @@ export function SettingsRoute() {
|
|||||||
remoteSubmitting={createWorkspaceRemoteBusy}
|
remoteSubmitting={createWorkspaceRemoteBusy}
|
||||||
remoteError={createWorkspaceRemoteError}
|
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
|
<ConnectionsModals
|
||||||
client={activeClient}
|
client={activeClient}
|
||||||
projectDir={selectedWorkspaceRoot}
|
projectDir={selectedWorkspaceRoot}
|
||||||
@@ -1336,7 +1345,7 @@ export function SettingsRoute() {
|
|||||||
if (!activeClient) return undefined;
|
if (!activeClient) return undefined;
|
||||||
return abortSessionSafe(activeClient, sessionId);
|
return abortSessionSafe(activeClient, sessionId);
|
||||||
}}
|
}}
|
||||||
onReloadEngine={systemState.reloadWorkspaceEngine}
|
onReloadEngine={reloadCoordinator.reloadWorkspaceEngine}
|
||||||
modalState={{
|
modalState={{
|
||||||
mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen,
|
mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen,
|
||||||
mcpAuthEntry: connectionsSnapshot.mcpAuthEntry,
|
mcpAuthEntry: connectionsSnapshot.mcpAuthEntry,
|
||||||
|
|||||||
@@ -486,23 +486,30 @@ async function fetchOpencodeJson(
|
|||||||
|
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.pathname = path.startsWith("/") ? path : `/${path}`;
|
url.pathname = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const directory = resolveOpencodeDirectory(workspace);
|
||||||
if (init.query instanceof URLSearchParams) {
|
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) {
|
} else if (init.query) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(init.query)) {
|
for (const [key, value] of Object.entries(init.query)) {
|
||||||
if (value === undefined || value === null) continue;
|
if (value === undefined || value === null) continue;
|
||||||
params.set(key, String(value));
|
params.set(key, String(value));
|
||||||
}
|
}
|
||||||
|
if (directory && !params.has("directory")) {
|
||||||
|
params.set("directory", directory);
|
||||||
|
}
|
||||||
url.search = params.toString();
|
url.search = params.toString();
|
||||||
} else {
|
} else {
|
||||||
url.search = "";
|
url.search = directory ? new URLSearchParams({ directory }).toString() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
const directory = resolveOpencodeDirectory(workspace);
|
|
||||||
if (directory) {
|
if (directory) {
|
||||||
headers.set("x-opencode-directory", directory);
|
headers.set("x-opencode-directory", directory);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user