diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css
index 62773a79..7456d0d1 100644
--- a/apps/app/src/app/index.css
+++ b/apps/app/src/app/index.css
@@ -286,3 +286,116 @@ select:disabled {
@utility animate-progress-shimmer {
animation: progress-shimmer 2s infinite linear;
}
+
+@keyframes ow-session-orbit {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes ow-session-orbit-reverse {
+ from {
+ transform: rotate(360deg);
+ }
+ to {
+ transform: rotate(0deg);
+ }
+}
+
+@keyframes ow-session-comet {
+ 0%,
+ 100% {
+ opacity: 0.58;
+ transform: translate(-50%, -50%) rotate(0deg) translateX(42px) scale(0.72);
+ }
+ 50% {
+ opacity: 1;
+ transform: translate(-50%, -50%) rotate(180deg) translateX(42px) scale(1.08);
+ }
+}
+
+@keyframes ow-session-glow {
+ 0%,
+ 100% {
+ opacity: 0.5;
+ transform: translate(-50%, -50%) scale(0.86);
+ }
+ 50% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1.12);
+ }
+}
+
+@keyframes ow-session-scan {
+ 0% {
+ opacity: 0;
+ transform: translateY(-22px) scaleX(0.6);
+ }
+ 18%,
+ 72% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(170px) scaleX(1);
+ }
+}
+
+@keyframes ow-session-core {
+ from {
+ transform: rotate(0deg) scale(0.94);
+ }
+ 50% {
+ transform: rotate(180deg) scale(1.04);
+ }
+ to {
+ transform: rotate(360deg) scale(0.94);
+ }
+}
+
+@keyframes ow-session-progress {
+ 0% {
+ transform: translateX(-120%);
+ }
+ 100% {
+ transform: translateX(240%);
+ }
+}
+
+.ow-session-orbit {
+ animation: ow-session-orbit 3.8s linear infinite;
+}
+
+.ow-session-orbit-reverse {
+ animation: ow-session-orbit-reverse 5.2s linear infinite;
+}
+
+.ow-session-comet {
+ animation: ow-session-comet 2.4s cubic-bezier(0.45, 0, 0.2, 1) infinite;
+}
+
+.ow-session-glow {
+ animation: ow-session-glow 3.2s ease-in-out infinite;
+}
+
+.ow-session-scan {
+ animation: ow-session-scan 2.8s ease-in-out infinite;
+}
+
+.ow-session-core {
+ animation: ow-session-core 2.6s ease-in-out infinite;
+}
+
+.ow-session-progress {
+ animation: ow-session-progress 1.7s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ow-session-wait * {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx
index 74e37528..00af629b 100644
--- a/apps/app/src/react-app/domains/session/chat/session-page.tsx
+++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx
@@ -29,6 +29,7 @@ import {
DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
useWorkspaceShellLayout,
} from "../../../shell/workspace-shell-layout";
+import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog";
type StatusBarOverrides = Pick<
StatusBarProps,
@@ -196,6 +197,14 @@ export function SessionPage(props: SessionPageProps) {
defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
expandedRightWidth: 280,
});
+ useReactRenderWatchdog("SessionPage", {
+ selectedSessionId: props.selectedSessionId,
+ selectedWorkspaceId: props.selectedWorkspaceId,
+ clientConnected: props.clientConnected,
+ startupPhase: props.startupPhase,
+ hasSurface: Boolean(props.surface),
+ workspaceCount: props.workspaces.length,
+ });
const [renameOpen, setRenameOpen] = useState(false);
const [renameTitle, setRenameTitle] = useState("");
@@ -442,15 +451,29 @@ export function SessionPage(props: SessionPageProps) {
) : null}
{showDelayedSessionLoadingState ? (
-
-
-
-
+
+
+
+
+
+
{t("session.loading_title")}
{t("session.loading_detail")}
+
) : null}
diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx
index 45e73cab..ccb3e39a 100644
--- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx
+++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx
@@ -24,6 +24,7 @@ import {
import { getReactQueryClient } from "../../../infra/query-client";
import { ReactSessionComposer } from "./composer/composer";
import { DevProfiler } from "../../../shell/dev-profiler";
+import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog";
import type { ReactComposerNotice } from "./composer/notice";
import { SessionDebugPanel } from "./debug-panel";
import { SessionTranscript } from "./message-list";
@@ -106,6 +107,40 @@ function useSharedQueryState
(queryKey: readonly unknown[], fallback: T) {
);
}
+function messageHasVisibleAssistantOutput(message: UIMessage) {
+ if (message.role !== "assistant") return false;
+ return message.parts.some((part) => {
+ if ("text" in part && typeof part.text === "string") return part.text.trim().length > 0;
+ return part.type === "dynamic-tool" || part.type === "file";
+ });
+}
+
+function AssistantWaitingCard() {
+ return (
+
+
+
+
+
+
Thinking...
+
Waiting for the first response token
+
+
+
+
+ );
+}
+
function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) {
if (!attachment.previewUrl) return;
URL.revokeObjectURL(attachment.previewUrl);
@@ -120,6 +155,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
const [error, setError] = useState(null);
const [sending, setSending] = useState(false);
const [showDelayedLoading, setShowDelayedLoading] = useState(false);
+ const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null);
const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null);
const [toolSkills, setToolSkills] = useState([]);
const [toolMcpServers, setToolMcpServers] = useState([]);
@@ -181,6 +217,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
setError(null);
setSending(false);
setShowDelayedLoading(false);
+ setAwaitingAssistantBaseline(null);
// Clear draft + attachments + mentions on session change so typed text
// doesn't bleed across sessions (and across workspaces). The sessionId
// effectively changes when the workspace changes too because the route
@@ -270,6 +307,23 @@ export function SessionSurface(props: SessionSurfaceProps) {
const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry";
const renderedMessages = transcriptState ?? [];
const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0;
+ const assistantOutputAfterAwaitStart = useMemo(() => {
+ if (awaitingAssistantBaseline === null) return false;
+ return renderedMessages
+ .slice(awaitingAssistantBaseline)
+ .some(messageHasVisibleAssistantOutput);
+ }, [awaitingAssistantBaseline, renderedMessages]);
+ const showAssistantWaitState = awaitingAssistantBaseline !== null && !assistantOutputAfterAwaitStart;
+ useReactRenderWatchdog("SessionSurface", {
+ sessionId: props.sessionId,
+ workspaceId: props.workspaceId,
+ messageCount: renderedMessages.length,
+ liveStatus: liveStatus.type,
+ sending,
+ pendingSessionLoad,
+ showAssistantWaitState,
+ hasSnapshot: Boolean(snapshot),
+ });
useEffect(() => {
if (!pendingSessionLoad) {
@@ -280,6 +334,17 @@ export function SessionSurface(props: SessionSurfaceProps) {
return () => window.clearTimeout(id);
}, [pendingSessionLoad]);
+ useEffect(() => {
+ if (awaitingAssistantBaseline === null) return;
+ if (assistantOutputAfterAwaitStart) {
+ setAwaitingAssistantBaseline(null);
+ return;
+ }
+ if (sending || liveStatus.type !== "idle" || renderedMessages.length <= awaitingAssistantBaseline) return;
+ const id = window.setTimeout(() => setAwaitingAssistantBaseline(null), 1200);
+ return () => window.clearTimeout(id);
+ }, [assistantOutputAfterAwaitStart, awaitingAssistantBaseline, liveStatus.type, renderedMessages.length, sending]);
+
const model = deriveSessionRenderModel({
intendedSessionId: props.sessionId,
renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null,
@@ -336,6 +401,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
// talking" behavior that the Solid composer had.
setError(null);
setSending(true);
+ setAwaitingAssistantBaseline(renderedMessages.length);
try {
const nextDraft = buildDraft(text, attachments);
await props.onSendDraft(nextDraft);
@@ -346,6 +412,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
setSending(false);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
+ setAwaitingAssistantBaseline(null);
setSending(false);
}
};
@@ -560,20 +627,23 @@ export function SessionSurface(props: SessionSurfaceProps) {
{error || (snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load React session view.")}
- ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
-
-
+ ) : renderedMessages.length === 0 && showAssistantWaitState ? (
+
+ ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
+ null
) : (
- scrollRef.current}
- />
+ <>
+ scrollRef.current}
+ />
+ {showAssistantWaitState ? : null}
+ >
)}
diff --git a/apps/app/src/react-app/kernel/system-state.ts b/apps/app/src/react-app/kernel/system-state.ts
index cb3145b3..eddc800d 100644
--- a/apps/app/src/react-app/kernel/system-state.ts
+++ b/apps/app/src/react-app/kernel/system-state.ts
@@ -31,8 +31,11 @@ export type ResetState = {
export type SystemStateControls = {
reload: ReloadState;
+ reloadCopy: { title: string; body: string };
markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void;
clearReloadRequired: () => void;
+ reloadWorkspaceEngine: () => Promise
;
+ canReloadWorkspaceEngine: boolean;
reset: ResetState;
openResetModal: (mode: ResetOpenworkMode) => void;
closeResetModal: () => void;
@@ -60,6 +63,9 @@ function clearOpenworkLocalStorage(mode: ResetOpenworkMode) {
type UseSystemStateOptions = {
hasActiveRuns: () => boolean;
+ reloadWorkspaceEngine?: () => Promise;
+ canReloadWorkspaceEngine?: () => boolean;
+ onReloadComplete?: () => void | Promise;
setError: (message: string | null) => void;
};
@@ -72,8 +78,8 @@ export function useSystemState(
number | null
>(null);
const [reloadTrigger, setReloadTrigger] = useState(null);
- const [reloadBusy] = useState(false);
- const [reloadError] = useState(null);
+ const [reloadBusy, setReloadBusy] = useState(false);
+ const [reloadError, setReloadError] = useState(null);
const [resetModalOpen, setResetModalOpen] = useState(false);
const [resetModalMode, setResetModalMode] =
@@ -111,8 +117,60 @@ export function useSystemState(
setReloadPending(false);
setReloadReasons([]);
setReloadTrigger(null);
+ setReloadError(null);
}, []);
+ const reloadCopy = useMemo(() => {
+ const title = t("system.reload_required");
+ const bodyKey =
+ reloadReasons.length === 1 && reloadReasons[0] === "plugins"
+ ? "system.reload_body_plugins"
+ : reloadReasons.length === 1 && reloadReasons[0] === "skills"
+ ? "system.reload_body_skills"
+ : reloadReasons.length === 1 && reloadReasons[0] === "agents"
+ ? "system.reload_body_agents"
+ : reloadReasons.length === 1 && reloadReasons[0] === "commands"
+ ? "system.reload_body_commands"
+ : reloadReasons.length === 1 && reloadReasons[0] === "config"
+ ? "system.reload_body_config"
+ : reloadReasons.length === 1 && reloadReasons[0] === "mcp"
+ ? "system.reload_body_mcp"
+ : reloadReasons.length > 0
+ ? "system.reload_body_mixed"
+ : "system.reload_body_default";
+ return { title, body: t(bodyKey) };
+ }, [reloadReasons]);
+
+ const canReloadWorkspaceEngine =
+ !reloadBusy && options.canReloadWorkspaceEngine?.() !== false;
+
+ const reloadWorkspaceEngine = useCallback(async () => {
+ if (reloadBusy) return;
+ if (options.canReloadWorkspaceEngine?.() === false) {
+ setReloadError(t("system.reload_unavailable"));
+ return;
+ }
+ setReloadBusy(true);
+ setReloadError(null);
+ options.setError(null);
+ try {
+ const ok = options.reloadWorkspaceEngine
+ ? await options.reloadWorkspaceEngine()
+ : false;
+ if (ok === false) {
+ setReloadError(t("system.reload_failed"));
+ return;
+ }
+ await options.onReloadComplete?.();
+ clearReloadRequired();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : safeStringify(error);
+ setReloadError(message || t("system.reload_failed"));
+ } finally {
+ setReloadBusy(false);
+ }
+ }, [clearReloadRequired, options, reloadBusy]);
+
const openResetModal = useCallback(
(mode: ResetOpenworkMode) => {
if (options.hasActiveRuns()) {
@@ -171,8 +229,11 @@ export function useSystemState(
reloadBusy,
reloadError,
},
+ reloadCopy,
markReloadRequired,
clearReloadRequired,
+ reloadWorkspaceEngine,
+ canReloadWorkspaceEngine,
reset: {
resetModalOpen,
resetModalMode,
@@ -192,7 +253,10 @@ export function useSystemState(
markReloadRequired,
openResetModal,
options.setError,
+ reloadCopy,
reloadBusy,
+ reloadWorkspaceEngine,
+ canReloadWorkspaceEngine,
reloadError,
reloadLastTriggeredAt,
reloadPending,
diff --git a/apps/app/src/react-app/shell/debug-logger.ts b/apps/app/src/react-app/shell/debug-logger.ts
index 9dba3bb9..c1ddff6e 100644
--- a/apps/app/src/react-app/shell/debug-logger.ts
+++ b/apps/app/src/react-app/shell/debug-logger.ts
@@ -173,6 +173,14 @@ function enqueue(entry: DevLogEntry) {
scheduleFlush();
}
+export function recordDebugLog(entry: DevLogEntry) {
+ if (!started || !isEnabled()) {
+ recordInspectorEvent(`log.${entry.level}`, entry);
+ return;
+ }
+ enqueue(entry);
+}
+
function isEnabled(): boolean {
if (typeof window === "undefined") return false;
// Always on in dev; explicit opt-out via `localStorage.openwork.debug.disableLogger = "1"`.
diff --git a/apps/app/src/react-app/shell/react-render-watchdog.ts b/apps/app/src/react-app/shell/react-render-watchdog.ts
new file mode 100644
index 00000000..0507c83b
--- /dev/null
+++ b/apps/app/src/react-app/shell/react-render-watchdog.ts
@@ -0,0 +1,101 @@
+/** @jsxImportSource react */
+import { useEffect } from "react";
+
+import { publishInspectorSlice, recordInspectorEvent } from "./app-inspector";
+import { recordDebugLog } from "./debug-logger";
+
+type RenderWatchdogDetails = Record;
+
+type RenderWatchdogStats = {
+ name: string;
+ totalCommits: number;
+ windowCommits: number;
+ windowStartedAt: number;
+ lastCommitAt: number;
+ lastWarnAt: number;
+ lastDetails?: RenderWatchdogDetails;
+};
+
+const WINDOW_MS = 2_000;
+const WARN_COMMIT_THRESHOLD = 40;
+const WARN_COOLDOWN_MS = 5_000;
+const statsByName = new Map();
+let inspectorInstalled = false;
+
+function compactStats() {
+ return Array.from(statsByName.values())
+ .map((stats) => ({
+ name: stats.name,
+ totalCommits: stats.totalCommits,
+ windowCommits: stats.windowCommits,
+ windowAgeMs: Math.max(0, Date.now() - stats.windowStartedAt),
+ lastCommitAgeMs: Math.max(0, Date.now() - stats.lastCommitAt),
+ lastWarnAgeMs: stats.lastWarnAt > 0 ? Math.max(0, Date.now() - stats.lastWarnAt) : null,
+ lastDetails: stats.lastDetails ?? null,
+ }))
+ .sort((a, b) => b.windowCommits - a.windowCommits || b.totalCommits - a.totalCommits);
+}
+
+function installInspectorSlice() {
+ if (inspectorInstalled) return;
+ inspectorInstalled = true;
+ publishInspectorSlice("reactRenderWatchdog", () => ({
+ windowMs: WINDOW_MS,
+ warnCommitThreshold: WARN_COMMIT_THRESHOLD,
+ components: compactStats(),
+ }));
+}
+
+function recordCommit(name: string, details?: RenderWatchdogDetails) {
+ installInspectorSlice();
+ const now = Date.now();
+ let stats = statsByName.get(name);
+ if (!stats) {
+ stats = {
+ name,
+ totalCommits: 0,
+ windowCommits: 0,
+ windowStartedAt: now,
+ lastCommitAt: now,
+ lastWarnAt: 0,
+ };
+ statsByName.set(name, stats);
+ }
+
+ if (now - stats.windowStartedAt > WINDOW_MS) {
+ stats.windowStartedAt = now;
+ stats.windowCommits = 0;
+ }
+
+ stats.totalCommits += 1;
+ stats.windowCommits += 1;
+ stats.lastCommitAt = now;
+ stats.lastDetails = details;
+
+ if (
+ stats.windowCommits >= WARN_COMMIT_THRESHOLD &&
+ now - stats.lastWarnAt > WARN_COOLDOWN_MS
+ ) {
+ stats.lastWarnAt = now;
+ const payload = {
+ component: name,
+ windowCommits: stats.windowCommits,
+ windowMs: WINDOW_MS,
+ totalCommits: stats.totalCommits,
+ details: details ?? null,
+ };
+ recordInspectorEvent("react.render_loop_suspected", payload);
+ recordDebugLog({
+ level: "warn",
+ source: "react-render-watchdog",
+ message: "react.render_loop_suspected",
+ extra: payload,
+ });
+ }
+}
+
+export function useReactRenderWatchdog(name: string, details?: RenderWatchdogDetails) {
+ useEffect(() => {
+ recordCommit(name, details);
+ });
+}
diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx
index e2287fbc..beee33bb 100644
--- a/apps/app/src/react-app/shell/session-route.tsx
+++ b/apps/app/src/react-app/shell/session-route.tsx
@@ -81,6 +81,7 @@ import {
publishInspectorSlice,
recordInspectorEvent,
} from "./app-inspector";
+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";
@@ -320,6 +321,15 @@ export function SessionRoute() {
// behavior pill actually shows its options (bug: was empty before).
const [providerCatalog, setProviderCatalog] = useState>>({});
const [openworkServerHostInfoState, setOpenworkServerHostInfoState] = useState(null);
+ useReactRenderWatchdog("SessionRoute", {
+ selectedSessionId,
+ selectedWorkspaceId,
+ loading,
+ workspaceCount: workspaces.length,
+ sessionGroupCount: Object.keys(sessionsByWorkspaceId).length,
+ commandPaletteOpen,
+ modelPickerOpen,
+ });
const [openworkServerSettingsVersion, setOpenworkServerSettingsVersion] = useState(0);
const [routeEngineInfo, setRouteEngineInfo] = useState(null);
const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = useState(false);
diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx
index a0c14656..b37e8292 100644
--- a/apps/app/src/react-app/shell/settings-route.tsx
+++ b/apps/app/src/react-app/shell/settings-route.tsx
@@ -1,5 +1,5 @@
/** @jsxImportSource react */
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { SUGGESTED_PLUGINS } from "../../app/constants";
@@ -21,6 +21,7 @@ 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 { TopRightNotifications } from "../domains/shell-feedback/top-right-notifications";
import { GeneralSettingsView } from "../domains/settings/pages/general-view";
import { AdvancedView } from "../domains/settings/pages/advanced-view";
import { AppearanceView } from "../domains/settings/pages/appearance-view";
@@ -38,6 +39,7 @@ 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,
@@ -65,6 +67,7 @@ import { ModelPickerModal } from "../domains/session/modals/model-picker-modal";
import type { ModelOption, ModelRef } from "../../app/types";
import { recordInspectorEvent } from "./app-inspector";
import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork";
+import { abortSessionSafe } from "../../app/lib/opencode-session";
type RouteWorkspace = OpenworkWorkspaceInfo & {
displayNameResolved: string;
@@ -192,6 +195,14 @@ function toSessionGroups(
}));
}
+function isActiveSessionStatus(status: unknown) {
+ return status === "running" || status === "retry" || status === "busy";
+}
+
+function getSessionStatus(session: any) {
+ return session?.status ?? session?.state ?? session?.runStatus ?? null;
+}
+
function parseSettingsPath(pathname: string): {
tab: SettingsTab;
redirectPath: string | null;
@@ -375,6 +386,55 @@ export function SettingsRoute() {
developerMode,
};
+ const activeReloadBlockingSessions = useMemo(
+ () =>
+ Object.values(sessionsByWorkspaceId)
+ .flat()
+ .filter((session) => isActiveSessionStatus(getSessionStatus(session)))
+ .map((session: any) => ({
+ id: String(session?.id ?? ""),
+ title:
+ String(session?.title ?? session?.slug ?? session?.id ?? "").trim() ||
+ t("session.untitled"),
+ }))
+ .filter((session) => session.id.length > 0),
+ [sessionsByWorkspaceId],
+ );
+
+ const reloadWorkspaceEngineFromUi = useCallback(async () => {
+ const workspaceId = routeStateRef.current.runtimeWorkspaceId?.trim() || selectedWorkspaceId.trim();
+ if (!openworkClient || !workspaceId) {
+ setRouteError(t("app.error_connect_first"));
+ return false;
+ }
+
+ await openworkClient.reloadEngine(workspaceId);
+
+ try {
+ window.dispatchEvent(new CustomEvent("openwork-server-settings-changed"));
+ } catch {
+ // ignore browser event dispatch failures
+ }
+
+ 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]);
+
const shellLayout = useWorkspaceShellLayout({
expandedRightWidth: 320,
defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH,
@@ -414,8 +474,9 @@ export function SettingsRoute() {
openworkServer: openworkServerStore,
runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId,
developerMode: () => routeStateRef.current.developerMode,
+ markReloadRequired: systemState.markReloadRequired,
}),
- [openworkServerStore],
+ [openworkServerStore, systemState.markReloadRequired],
);
const providerAuthStore = useMemo(
() =>
@@ -435,9 +496,14 @@ export function SettingsRoute() {
setDisabledProviders,
markOpencodeConfigReloadRequired: () => {
setConfigActionStatus(t("settings.config_updated"));
+ systemState.markReloadRequired("config", {
+ type: "config",
+ name: "opencode.json",
+ action: "updated",
+ });
},
}),
- [openworkServerStore],
+ [openworkServerStore, systemState.markReloadRequired],
);
const extensionsStore = useMemo(
() =>
@@ -453,8 +519,9 @@ export function SettingsRoute() {
setBusyLabel,
setBusyStartedAt: () => {},
setError: setRouteError,
+ markReloadRequired: systemState.markReloadRequired,
}),
- [openworkServerStore],
+ [openworkServerStore, systemState.markReloadRequired],
);
const automationsStore = useMemo(
() =>
@@ -937,11 +1004,9 @@ export function SettingsRoute() {
addPlugin={async () => {
setRouteError("Scheduler plugin install is not wired into the React settings route yet.");
}}
- reloadWorkspaceEngine={async () => {
- setRouteError("Workspace reload is not wired into the React settings route yet.");
- }}
- reloadBusy={false}
- canReloadWorkspace={false}
+ reloadWorkspaceEngine={systemState.reloadWorkspaceEngine}
+ reloadBusy={systemState.reload.reloadBusy}
+ canReloadWorkspace={systemState.canReloadWorkspaceEngine}
openLink={(url) => platform.openLink(url)}
/>
);
@@ -1065,12 +1130,10 @@ export function SettingsRoute() {
updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings,
resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings,
testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection,
- canReloadWorkspace: false,
- reloadWorkspaceEngine: async () => {
- setRouteError("Workspace reload is not wired into the React settings route yet.");
- },
- reloadBusy: false,
- reloadError: routeError,
+ canReloadWorkspace: systemState.canReloadWorkspaceEngine,
+ reloadWorkspaceEngine: systemState.reloadWorkspaceEngine,
+ reloadBusy: systemState.reload.reloadBusy,
+ reloadError: systemState.reload.reloadError ?? routeError,
developerMode,
}}
/>
@@ -1236,15 +1299,40 @@ export function SettingsRoute() {
remoteSubmitting={createWorkspaceRemoteBusy}
remoteError={createWorkspaceRemoteError}
/>
+ 0
+ ? t("app.reload_stop_tasks")
+ : t("app.reload_now")
+ }
+ dismissLabel={t("app.reload_later")}
+ reloadBusy={systemState.reload.reloadBusy}
+ canReload={systemState.canReloadWorkspaceEngine}
+ hasActiveRuns={activeReloadBlockingSessions.length > 0}
+ onReload={() => {
+ void (activeReloadBlockingSessions.length > 0
+ ? forceStopActiveSessionsAndReload()
+ : systemState.reloadWorkspaceEngine());
+ }}
+ onDismissReload={systemState.clearReloadRequired}
+ />
0}
+ activeSessions={activeReloadBlockingSessions}
isRemoteWorkspace={selectedWorkspace?.workspaceType === "remote"}
- onForceStopSession={() => undefined}
- onReloadEngine={() => undefined}
+ onForceStopSession={(sessionId) => {
+ if (!activeClient) return undefined;
+ return abortSessionSafe(activeClient, sessionId);
+ }}
+ onReloadEngine={systemState.reloadWorkspaceEngine}
modalState={{
mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen,
mcpAuthEntry: connectionsSnapshot.mcpAuthEntry,
diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs
index 1831e3ae..08d3bc86 100644
--- a/apps/desktop/electron/runtime.mjs
+++ b/apps/desktop/electron/runtime.mjs
@@ -287,6 +287,17 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
const orchestratorState = createOrchestratorState();
const routerState = createRouterState();
+ // Serialize engine lifecycle operations. Without this, concurrent renderer
+ // invocations of engineStart/engineStop/engineRestart race: each call's
+ // stopAllRuntimeChildren kills the previous call's freshly-spawned
+ // orchestrator daemon, and the prior call then times out its /health probe.
+ let runtimeLifecycleQueue = Promise.resolve();
+ function withRuntimeLifecycle(fn) {
+ const next = runtimeLifecycleQueue.then(fn, fn);
+ runtimeLifecycleQueue = next.catch(() => {});
+ return next;
+ }
+
const userDataDir = app.getPath("userData");
const sidecarDirs = [
path.join(desktopRoot, "src-tauri", "sidecars"),
@@ -1526,9 +1537,9 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths
}
return {
- engineStart,
- engineStop,
- engineRestart,
+ engineStart: (projectDir, options) => withRuntimeLifecycle(() => engineStart(projectDir, options)),
+ engineStop: () => withRuntimeLifecycle(() => engineStop()),
+ engineRestart: (options) => withRuntimeLifecycle(() => engineRestart(options)),
engineInfo,
engineInstall,
openworkServerInfo,
diff --git a/scripts/openwork-debug.sh b/scripts/openwork-debug.sh
index 14020fe2..9a72e00f 100755
--- a/scripts/openwork-debug.sh
+++ b/scripts/openwork-debug.sh
@@ -9,18 +9,28 @@
# tail live tail pnpm dev + the /dev/log sink
# sink print the dev log sink path
# kill-orphans remove orphan openwork/opencode processes (ppid == launchd)
+# diagnose-hang classify Electron crash/hang/sidecar/app-state failures
# stop full, layered teardown of the dev stack (no cache wipe)
# start launch pnpm dev in the background with the log sink on
# wait-healthy block until openwork-server reports /health = 200
# reset stop + wipe Vite dep cache + truncate log sink + start
# restart alias for reset
#
-# Teardown ordering (important):
-# 1. pnpm dev (parent supervisor)
-# 2. tauri dev (Rust dev runner, if still alive)
-# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/
-# 4. Vite (node node_modules/.../vite)
-# 5. orchestrator + openwork-server + opencode + opencode-router orphans
+# Variant (OPENWORK_DEV_VARIANT):
+# electron (default) pnpm --filter @openwork/desktop dev:electron
+# Electron shell + CDP on 127.0.0.1:9823 for chrome-devtools MCP.
+# Sidecars run from apps/desktop/src-tauri/sidecars/*.
+# tauri legacy: pnpm dev (Tauri dev webview, no CDP).
+# Sidecars run from apps/desktop/src-tauri/target/debug/*.
+#
+# Teardown ordering (important, both variants):
+# 1. pnpm dev / dev:electron supervisor (parent supervisor)
+# 2. tauri dev (Rust dev runner, if still alive)
+# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/
+# 4. Electron main+helpers (node_modules/electron/...Electron.app)
+# 5. Vite (node node_modules/.../vite)
+# 6. orchestrator + openwork-server + opencode + opencode-router
+# (both target/debug/* and src-tauri/sidecars/* trees)
#
# Cache/ephemeral state wiped by `reset`:
# - Vite dep pre-bundle cache: apps/app/node_modules/.vite
@@ -49,6 +59,17 @@ DEV_LOG_FILE="${OPENWORK_DEV_LOG_FILE:-$HOME/.openwork/debug/openwork-dev.log}"
PNPM_DEV_LOG="${OPENWORK_PNPM_DEV_LOG:-/tmp/openwork-test/pnpm-dev.log}"
PNPM_DEV_PID_FILE="${OPENWORK_PNPM_DEV_PID:-/tmp/openwork-test/pnpm-dev.pid}"
WAIT_HEALTHY_SECS="${OPENWORK_WAIT_HEALTHY_SECS:-90}"
+ELECTRON_CDP_PORT="${OPENWORK_ELECTRON_REMOTE_DEBUG_PORT:-9823}"
+
+# Dev variant. 'electron' (default) launches pnpm dev:electron with CDP on
+# 127.0.0.1:9823 so chrome-devtools MCP can attach. 'tauri' preserves the
+# legacy pnpm dev (Tauri webview) for users still on that path.
+DEV_VARIANT="${OPENWORK_DEV_VARIANT:-electron}"
+case "$DEV_VARIANT" in electron|tauri) ;; *)
+ printf '[openwork-debug] unknown OPENWORK_DEV_VARIANT=%s (expected electron|tauri)\n' "$DEV_VARIANT" >&2
+ exit 2
+ ;;
+esac
# ---------------------------------------------------------------------------
# Helpers
@@ -90,16 +111,92 @@ kill_pid_file() {
}
discover_openwork_server_port() {
- ps -Ao command | grep "target/debug/openwork-server" | grep -v grep \
- | grep -oE '\-\-port [0-9]+' | head -1 | awk '{print $2}'
+ ps -Ao command \
+ | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-server" \
+ | grep -v grep \
+ | grep -oE '\-\-port [0-9]+' \
+ | head -1 \
+ | awk '{print $2}' \
+ || true
+}
+
+electron_renderer_stats() {
+ ps -axo pid,ppid,pcpu,pmem,rss,command \
+ | awk '/Electron Helper \(Renderer\)/ && /com\.differentai\.openwork/ && !/awk/ && !/grep/ {print; found=1} END {exit found ? 0 : 1}' \
+ || true
+}
+
+electron_renderer_pid() {
+ electron_renderer_stats | awk 'NR == 1 {print $1}'
+}
+
+electron_renderer_cpu() {
+ electron_renderer_stats | awk 'NR == 1 {print $3}'
+}
+
+probe_electron_page_cdp() {
+ node <<'NODE'
+const port = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT || "9823";
+const controller = new AbortController();
+const fail = (message) => {
+ console.error(message);
+ process.exit(1);
+};
+const timeout = setTimeout(() => controller.abort(), 1800);
+let targets;
+try {
+ const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: controller.signal });
+ targets = await response.json();
+} catch (error) {
+ clearTimeout(timeout);
+ fail(`target-list-failed: ${error instanceof Error ? error.message : String(error)}`);
+}
+clearTimeout(timeout);
+const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
+if (!page) fail("no-page-target");
+
+const ws = new WebSocket(page.webSocketDebuggerUrl);
+let settled = false;
+const timer = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ try { ws.close(); } catch {}
+ fail("page-cdp-timeout");
+}, 2200);
+ws.onopen = () => {
+ ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1+1", returnByValue: true } }));
+};
+ws.onerror = () => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ fail("page-cdp-websocket-error");
+};
+ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.id !== 1) return;
+ settled = true;
+ clearTimeout(timer);
+ try { ws.close(); } catch {}
+ if (message.error) fail(`page-cdp-error: ${message.error.message ?? JSON.stringify(message.error)}`);
+ console.log("ok");
+ process.exit(0);
+ } catch (error) {
+ settled = true;
+ clearTimeout(timer);
+ fail(`page-cdp-parse-error: ${error instanceof Error ? error.message : String(error)}`);
+ }
+};
+NODE
}
# ---------------------------------------------------------------------------
# Public subcommands
snapshot() {
- echo "=== dev stack processes (target/debug tree) ==="
- ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|vite|pnpm dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20
+ echo "=== dev stack processes ==="
+ ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|node_modules\/electron\/dist\/Electron\.app\/Contents\/MacOS\/Electron|apps\/desktop\/scripts\/electron-dev\.mjs|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|apps\/desktop\/src-tauri\/sidecars\/openwork-server|apps\/desktop\/src-tauri\/sidecars\/openwork-orchestrator|apps\/desktop\/src-tauri\/sidecars\/opencode( |\/)|apps\/desktop\/src-tauri\/sidecars\/opencode-router|vite|pnpm .*dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20
echo
echo "=== openwork-server ==="
@@ -116,7 +213,13 @@ snapshot() {
echo
echo "=== opencode (via orchestrator) ==="
local oc_port
- oc_port=$(ps -Ao command | grep "target/debug/openwork-orchestrator" | grep -v grep | grep -oE '\-\-opencode-port [0-9]+' | head -1 | awk '{print $2}')
+ oc_port=$(ps -Ao command \
+ | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-orchestrator" \
+ | grep -v grep \
+ | grep -oE '\-\-opencode-port [0-9]+' \
+ | head -1 \
+ | awk '{print $2}' \
+ || true)
if [[ -z "$oc_port" ]]; then
echo " (no opencode port)"
else
@@ -128,7 +231,13 @@ snapshot() {
echo
echo "=== opencode-router ==="
local r_port
- r_port=$(ps -Ao command | grep "target/debug/opencode-router" | grep -v grep | grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' | head -1 | awk '{print $2}')
+ r_port=$(ps -Ao command \
+ | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/opencode-router" \
+ | grep -v grep \
+ | grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' \
+ | head -1 \
+ | awk '{print $2}' \
+ || true)
if [[ -z "$r_port" ]]; then
echo " (no opencode-router info)"
else
@@ -178,6 +287,148 @@ kill_orphans() {
kill -9 $pids 2>/dev/null || true
}
+diagnose_hang() {
+ local now
+ now=$(date "+%Y-%m-%dT%H:%M:%S%z")
+ echo "=== openwork hang diagnosis ==="
+ echo "time=$now"
+ echo "repo=$REPO_ROOT"
+ echo "cdp=http://127.0.0.1:$ELECTRON_CDP_PORT"
+ echo
+
+ echo "=== browser CDP ==="
+ local browser_json target_json
+ browser_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/version" 2>&1 || true)
+ if [[ "$browser_json" == *"webSocketDebuggerUrl"* ]]; then
+ echo " browser: responsive"
+ printf '%s\n' "$browser_json" | sed -n '1,8p'
+ else
+ echo " browser: not reachable"
+ printf ' %s\n' "$browser_json"
+ fi
+
+ echo
+ echo "=== page target ==="
+ target_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/list" 2>&1 || true)
+ if [[ "$target_json" == *"webSocketDebuggerUrl"* ]]; then
+ printf '%s\n' "$target_json" | sed -n '1,20p'
+ else
+ echo " no page target (or target list failed)"
+ printf ' %s\n' "$target_json"
+ fi
+
+ echo
+ echo "=== renderer process ==="
+ local renderer_stats renderer_pid renderer_cpu
+ renderer_stats=$(electron_renderer_stats)
+ renderer_pid=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $1}')
+ renderer_cpu=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $3}')
+ if [[ -n "$renderer_pid" ]]; then
+ echo " renderer: alive"
+ echo " PID PPID %CPU %MEM RSS COMMAND"
+ printf ' %s\n' "$renderer_stats" | sed -E 's#/Users/[^ ]*/#…/#g'
+ else
+ echo " renderer: missing"
+ fi
+
+ echo
+ echo "=== page CDP probe ==="
+ local page_probe="skipped"
+ if [[ "$browser_json" == *"webSocketDebuggerUrl"* && "$target_json" == *"webSocketDebuggerUrl"* ]]; then
+ if page_probe=$(OPENWORK_ELECTRON_REMOTE_DEBUG_PORT="$ELECTRON_CDP_PORT" probe_electron_page_cdp 2>&1); then
+ echo " page: responsive ($page_probe)"
+ page_probe="ok"
+ else
+ echo " page: unresponsive ($page_probe)"
+ page_probe="failed"
+ fi
+ else
+ echo " page: skipped (browser or page target unavailable)"
+ fi
+
+ echo
+ echo "=== classification ==="
+ local high_cpu="false"
+ if [[ -n "${renderer_cpu:-}" ]]; then
+ high_cpu=$(awk -v cpu="$renderer_cpu" 'BEGIN {print (cpu + 0 >= 80) ? "true" : "false"}')
+ fi
+ if [[ "$browser_json" != *"webSocketDebuggerUrl"* ]]; then
+ echo " no-electron-cdp: Electron is down or was not launched with CDP."
+ elif [[ "$target_json" == *"webSocketDebuggerUrl"* && -z "$renderer_pid" ]]; then
+ echo " renderer-crashed: browser CDP advertises a page but no renderer process exists."
+ echo " next: inspect latest macOS .ips crash report below."
+ elif [[ -n "$renderer_pid" && "$page_probe" == "failed" && "$high_cpu" == "true" ]]; then
+ echo " renderer-hung-hot: renderer exists, page CDP timed out, CPU >= 80%."
+ echo " next: use the sample captured below; logs will usually stop once wedged."
+ elif [[ -n "$renderer_pid" && "$page_probe" == "failed" ]]; then
+ echo " renderer-unresponsive: renderer exists but page CDP timed out."
+ echo " next: sample + check memory/CPU; could be blocked main thread or native stall."
+ elif [[ -n "$renderer_pid" && "$page_probe" == "ok" ]]; then
+ echo " renderer-responsive: likely app-state or sidecar/API issue, not a renderer hang."
+ else
+ echo " unknown: insufficient signal."
+ fi
+
+ echo
+ echo "=== latest crash report ==="
+ local crash
+ crash=$(ls -t "$HOME"/Library/Logs/DiagnosticReports/*"Electron Helper (Renderer)"*.ips 2>/dev/null | head -1 || true)
+ if [[ -n "$crash" ]]; then
+ ls -la "$crash"
+ sed -n '1,55p' "$crash" 2>/dev/null | sed -n '1,25p'
+ else
+ echo " no Electron renderer .ips crash report found"
+ fi
+
+ echo
+ echo "=== sidecar health ==="
+ local port
+ port=$(discover_openwork_server_port)
+ if [[ -n "$port" ]]; then
+ echo " openwork-server port=$port"
+ curl -sS --max-time 2 "http://127.0.0.1:$port/health" || echo "unreachable"
+ echo
+ else
+ echo " no openwork-server port discovered"
+ fi
+
+ echo
+ echo "=== recent dev sink ==="
+ echo " path=$DEV_LOG_FILE"
+ if [[ -f "$DEV_LOG_FILE" ]]; then
+ ls -la "$DEV_LOG_FILE"
+ tail -40 "$DEV_LOG_FILE"
+ else
+ echo " missing"
+ fi
+
+ echo
+ echo "=== recent pnpm log ==="
+ echo " path=$PNPM_DEV_LOG"
+ if [[ -f "$PNPM_DEV_LOG" ]]; then
+ ls -la "$PNPM_DEV_LOG"
+ tail -80 "$PNPM_DEV_LOG"
+ else
+ echo " missing"
+ fi
+
+ echo
+ echo "=== renderer sample ==="
+ if [[ -n "$renderer_pid" && ( "$page_probe" == "failed" || "$high_cpu" == "true" ) ]]; then
+ local sample_file="/tmp/openwork-renderer-${renderer_pid}-$(date +%Y%m%dT%H%M%S).sample.txt"
+ if sample "$renderer_pid" 3 -file "$sample_file" >/dev/null 2>&1; then
+ echo " captured=$sample_file"
+ grep -n "Thread_.*CrRendererMain\|Call graph:\|Physical footprint" "$sample_file" | head -20 || true
+ else
+ echo " sample failed for pid=$renderer_pid"
+ fi
+ elif [[ -n "$renderer_pid" ]]; then
+ echo " skipped (renderer responsive and CPU not high). Set OPENWORK_FORCE_SAMPLE=1 not currently supported."
+ else
+ echo " skipped (no renderer process to sample)."
+ fi
+}
+
# Ordered teardown. Safe to run when nothing is up; each step is idempotent.
stop() {
log "stopping dev stack (layered)"
@@ -195,10 +446,20 @@ stop() {
kill_by_pattern "tauri(-cli)? +dev"
kill_by_pattern "@tauri-apps/cli"
+ # 2b. Electron variant supervisor (scripts/electron-dev.mjs). Idempotent if
+ # the current variant is tauri; harmless if nothing matches.
+ kill_by_pattern "apps/desktop/scripts/electron-dev\.mjs"
+
# 3. Tauri dev webview — match full path so the installed /Applications/
# prod bundle is never targeted.
kill_by_pattern "target/debug/OpenWork-Dev"
+ # 3b. Electron main (the helpers die with the main via mach-port rendezvous
+ # loss; we still pattern-match them below in case a crash left orphans).
+ # Scoped to this repo's node_modules/electron so /Applications/Slack,
+ # Cursor, VSCode, etc. are never touched.
+ kill_by_pattern "node_modules/electron/dist/Electron\.app/Contents/MacOS/Electron"
+
# 4. Vite. Match the node process that loads the vite binary from this
# repo's node_modules, not any arbitrary node process on the host.
kill_by_pattern "node_modules/\.bin/vite"
@@ -207,10 +468,16 @@ stop() {
# 5. openwork-server / orchestrator / opencode / opencode-router for the
# current dev build. These are the longest-lived children and the ones
# most likely to orphan after an unclean shutdown.
+ # Tauri dev runs them from target/debug/, Electron dev runs them from
+ # src-tauri/sidecars/ — kill both trees, both are idempotent.
kill_by_pattern "target/debug/openwork-server"
kill_by_pattern "target/debug/openwork-orchestrator"
kill_by_pattern "target/debug/opencode"
kill_by_pattern "target/debug/opencode-router"
+ kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-server"
+ kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-orchestrator"
+ kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode( |/)"
+ kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode-router"
# Safety net for stragglers we don't own directly.
kill_orphans
@@ -233,11 +500,22 @@ start() {
fi
fi
- log "starting pnpm dev (log sink: $DEV_LOG_FILE)"
cd "$REPO_ROOT"
- env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
- nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 &
- local pid=$!
+ local pid
+ case "$DEV_VARIANT" in
+ electron)
+ log "starting pnpm dev:electron (variant=electron, log sink: $DEV_LOG_FILE, CDP: 127.0.0.1:9823)"
+ env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
+ nohup pnpm --filter @openwork/desktop dev:electron >"$PNPM_DEV_LOG" 2>&1 &
+ pid=$!
+ ;;
+ tauri)
+ log "starting pnpm dev (variant=tauri, log sink: $DEV_LOG_FILE)"
+ env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \
+ nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 &
+ pid=$!
+ ;;
+ esac
disown "$pid" 2>/dev/null || true
echo "$pid" >"$PNPM_DEV_PID_FILE"
log "pnpm dev pid=$pid"
@@ -282,8 +560,17 @@ reset() {
echo
snapshot | sed -n '1,20p'
echo
- log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop"
- log "its in-memory module cache and pick up the fresh Vite."
+ case "$DEV_VARIANT" in
+ electron)
+ log "reset complete (variant=electron) — Electron CDP should be up at"
+ log "http://127.0.0.1:9823; chrome-devtools MCP can attach there."
+ log "If the window looks stale, Cmd+Shift+R to drop its Vite module cache."
+ ;;
+ tauri)
+ log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop"
+ log "its in-memory module cache and pick up the fresh Vite."
+ ;;
+ esac
}
reset_webview_state() {
@@ -317,6 +604,9 @@ case "$cmd" in
kill-orphans)
kill_orphans
;;
+ diagnose-hang)
+ diagnose_hang
+ ;;
stop)
stop
;;