mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(startup): move boot orchestration to TS and cut desktop sync stalls (#1366)
Shift startup/loading control to TS with explicit boot phases, cached->live sidebar transitions, and startup trace markers while reducing Rust-side blocking in workspace and orchestrator status paths to improve macOS responsiveness during boot and workspace switching. Made-with: Cursor
This commit is contained in:
@@ -125,6 +125,13 @@ import {
|
||||
stripBundleQuery,
|
||||
} from "./bundles";
|
||||
import { createBundlesStore } from "./bundles/store";
|
||||
import {
|
||||
classifyStartupBranch,
|
||||
pushStartupTraceEvent,
|
||||
type BootPhase,
|
||||
type StartupBranch,
|
||||
type StartupTraceEvent,
|
||||
} from "./lib/startup-boot";
|
||||
|
||||
type SettingsReturnTarget = {
|
||||
view: View;
|
||||
@@ -138,6 +145,27 @@ type PendingInitialSessionSelection = {
|
||||
readyAt: number;
|
||||
};
|
||||
|
||||
const STARTUP_SESSION_SNAPSHOT_KEY = "openwork.startupSessionSnapshot.v1";
|
||||
const STARTUP_SESSION_SNAPSHOT_VERSION = 1;
|
||||
const STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE = 12;
|
||||
|
||||
type StartupSessionSnapshotEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
parentID?: string | null;
|
||||
directory?: string | null;
|
||||
time?: {
|
||||
updated?: number | null;
|
||||
created?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type StartupSessionSnapshot = {
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
sessionsByWorkspaceId: Record<string, StartupSessionSnapshotEntry[]>;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { resetSessionDisplayPreferences } = useSessionDisplayPreferences();
|
||||
const envOpenworkWorkspaceId =
|
||||
@@ -321,6 +349,8 @@ export default function App() {
|
||||
if (!isTauriRuntime()) return;
|
||||
if (!developerMode()) return;
|
||||
if (!documentVisible()) return;
|
||||
if (booting()) return;
|
||||
if (workspaceStore?.connectingWorkspaceId?.()) return;
|
||||
|
||||
let busy = false;
|
||||
|
||||
@@ -353,10 +383,40 @@ export default function App() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [opencodeConnectStatus, setOpencodeConnectStatus] = createSignal<OpencodeConnectStatus | null>(null);
|
||||
const [booting, setBooting] = createSignal(true);
|
||||
const [bootPhase, setBootPhase] = createSignal<BootPhase>("nativeInit");
|
||||
const [startupBranch, setStartupBranch] = createSignal<StartupBranch>("unknown");
|
||||
const [startupTrace, setStartupTrace] = createSignal<StartupTraceEvent[]>([]);
|
||||
const [firstSidebarVisibleAt, setFirstSidebarVisibleAt] = createSignal<number | null>(null);
|
||||
const [firstSessionPaintAt, setFirstSessionPaintAt] = createSignal<number | null>(null);
|
||||
const [, setLastKnownConfigSnapshot] = createSignal("");
|
||||
const [developerMode, setDeveloperMode] = createSignal(false);
|
||||
const [documentVisible, setDocumentVisible] = createSignal(true);
|
||||
|
||||
const markStartupTrace = (phase: BootPhase, event: string, detail?: Record<string, unknown>) => {
|
||||
setStartupTrace((current) =>
|
||||
pushStartupTraceEvent(current, {
|
||||
at: Date.now(),
|
||||
phase,
|
||||
event,
|
||||
...(detail ? { detail } : {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const phase = bootPhase();
|
||||
const isBooting = phase !== "ready" && phase !== "error";
|
||||
setBooting(isBooting);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (bootPhase() === "ready" || bootPhase() === "error") return;
|
||||
const message = error();
|
||||
if (!message) return;
|
||||
setBootPhase("error");
|
||||
markStartupTrace("error", "startup-error", { message });
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (developerMode()) return;
|
||||
clearDevLogs();
|
||||
@@ -549,6 +609,9 @@ export default function App() {
|
||||
const activeSessionStatusById = createMemo(() => sessionStatusById());
|
||||
const activeTodos = createMemo(() => todos());
|
||||
const activeWorkingFiles = createMemo(() => workingFiles());
|
||||
const [startupSessionSnapshotByWorkspaceId, setStartupSessionSnapshotByWorkspaceId] = createSignal<
|
||||
Record<string, StartupSessionSnapshotEntry[]>
|
||||
>({});
|
||||
|
||||
const [sessionsLoaded, setSessionsLoaded] = createSignal(false);
|
||||
const loadSessionsWithReady = async (scopeRoot?: string) => {
|
||||
@@ -556,6 +619,20 @@ export default function App() {
|
||||
setSessionsLoaded(true);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STARTUP_SESSION_SNAPSHOT_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as StartupSessionSnapshot;
|
||||
if (!parsed || parsed.version !== STARTUP_SESSION_SNAPSHOT_VERSION) return;
|
||||
if (!parsed.sessionsByWorkspaceId || typeof parsed.sessionsByWorkspaceId !== "object") return;
|
||||
setStartupSessionSnapshotByWorkspaceId(parsed.sessionsByWorkspaceId);
|
||||
} catch {
|
||||
// ignore malformed snapshots
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!client()) {
|
||||
setSessionsLoaded(false);
|
||||
@@ -764,12 +841,55 @@ export default function App() {
|
||||
openworkServer: openworkServerStore,
|
||||
openworkEnvWorkspaceId: envOpenworkWorkspaceId,
|
||||
onEngineStable: () => {},
|
||||
onBootPhaseChange: (phase, detail) => {
|
||||
setBootPhase(phase);
|
||||
markStartupTrace(phase, "phase-change", detail);
|
||||
},
|
||||
onStartupBranch: (branch, detail) => {
|
||||
setStartupBranch(branch);
|
||||
markStartupTrace(bootPhase(), "branch", { branch, ...(detail ?? {}) });
|
||||
},
|
||||
onStartupTrace: (event, detail) => {
|
||||
markStartupTrace(bootPhase(), event, detail);
|
||||
},
|
||||
engineRuntime,
|
||||
developerMode,
|
||||
pendingInitialSessionSelection,
|
||||
setPendingInitialSessionSelection,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (startupBranch() !== "unknown") return;
|
||||
const active = workspaceStore.selectedWorkspaceInfo?.() ?? null;
|
||||
const derived = classifyStartupBranch({
|
||||
workspaceCount: workspaceStore.workspaces().length,
|
||||
activeWorkspaceType: active?.workspaceType ?? null,
|
||||
startupPreference: startupPreference(),
|
||||
engineHasBaseUrl: Boolean(workspaceStore.engine()?.baseUrl),
|
||||
selectedWorkspacePath: workspaceStore.selectedWorkspacePath?.() ?? "",
|
||||
});
|
||||
if (derived !== "unknown") {
|
||||
setStartupBranch(derived);
|
||||
markStartupTrace(bootPhase(), "branch-derived", { branch: derived });
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!developerMode()) return;
|
||||
const payload = {
|
||||
phase: bootPhase(),
|
||||
branch: startupBranch(),
|
||||
events: startupTrace(),
|
||||
};
|
||||
try {
|
||||
(window as { __openworkStartupTrace?: typeof payload }).__openworkStartupTrace = payload;
|
||||
console.log("[startup-trace]", payload);
|
||||
} catch {
|
||||
// ignore trace publishing failures
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
providerAuthModalOpen,
|
||||
providerAuthBusy,
|
||||
@@ -974,6 +1094,98 @@ export default function App() {
|
||||
});
|
||||
});
|
||||
|
||||
const hydratedSidebarWorkspaceGroups = createMemo<WorkspaceSessionGroup[]>(() => {
|
||||
const liveGroups = sidebarWorkspaceGroups();
|
||||
if (liveGroups.some((group) => group.sessions.length > 0)) {
|
||||
return liveGroups;
|
||||
}
|
||||
|
||||
const snapshotByWorkspaceId = startupSessionSnapshotByWorkspaceId();
|
||||
if (!snapshotByWorkspaceId || Object.keys(snapshotByWorkspaceId).length === 0) {
|
||||
return liveGroups;
|
||||
}
|
||||
|
||||
return liveGroups.map((group) => {
|
||||
if (group.sessions.length > 0) return group;
|
||||
const cachedSessions = snapshotByWorkspaceId[group.workspace.id] ?? [];
|
||||
if (!cachedSessions.length) return group;
|
||||
return {
|
||||
...group,
|
||||
sessions: cachedSessions,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const sidebarHydratedFromCache = createMemo(() => {
|
||||
const liveGroups = sidebarWorkspaceGroups();
|
||||
const hydratedGroups = hydratedSidebarWorkspaceGroups();
|
||||
if (!hydratedGroups.length) return false;
|
||||
if (liveGroups.length !== hydratedGroups.length) return false;
|
||||
return hydratedGroups.some((group, index) => {
|
||||
const liveGroup = liveGroups[index];
|
||||
if (!liveGroup) return false;
|
||||
return liveGroup.sessions.length === 0 && group.sessions.length > 0;
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (firstSidebarVisibleAt()) return;
|
||||
const anyRowsVisible = hydratedSidebarWorkspaceGroups().some((group) => group.sessions.length > 0);
|
||||
if (!anyRowsVisible) return;
|
||||
const at = Date.now();
|
||||
setFirstSidebarVisibleAt(at);
|
||||
markStartupTrace(bootPhase(), "first-sidebar-visible", {
|
||||
at,
|
||||
source: sidebarHydratedFromCache() ? "cache" : "live",
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (firstSessionPaintAt()) return;
|
||||
if (currentView() !== "session") return;
|
||||
const selected = activeSessionId();
|
||||
if (!selected) return;
|
||||
const hasVisibleSessionSurface = visibleMessages().length > 0 || sessionsLoaded();
|
||||
if (!hasVisibleSessionSurface) return;
|
||||
const at = Date.now();
|
||||
setFirstSessionPaintAt(at);
|
||||
markStartupTrace(bootPhase(), "first-session-paint", { at, sessionId: selected });
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!sessionsLoaded()) return;
|
||||
|
||||
const groups = sidebarWorkspaceGroups();
|
||||
const sessionsByWorkspaceId: Record<string, StartupSessionSnapshotEntry[]> = {};
|
||||
for (const group of groups) {
|
||||
if (!group.sessions.length) continue;
|
||||
sessionsByWorkspaceId[group.workspace.id] = group.sessions
|
||||
.slice(0, STARTUP_SESSION_SNAPSHOT_MAX_PER_WORKSPACE)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
parentID: session.parentID ?? null,
|
||||
directory: session.directory ?? null,
|
||||
time: session.time,
|
||||
}));
|
||||
}
|
||||
if (Object.keys(sessionsByWorkspaceId).length === 0) return;
|
||||
|
||||
const payload: StartupSessionSnapshot = {
|
||||
version: STARTUP_SESSION_SNAPSHOT_VERSION,
|
||||
updatedAt: Date.now(),
|
||||
sessionsByWorkspaceId,
|
||||
};
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STARTUP_SESSION_SNAPSHOT_KEY, JSON.stringify(payload));
|
||||
setStartupSessionSnapshotByWorkspaceId(sessionsByWorkspaceId);
|
||||
} catch {
|
||||
// ignore storage write failures
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const workspaceId = workspaceStore.selectedWorkspaceId();
|
||||
@@ -1556,7 +1768,7 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
|
||||
void workspaceStore.bootstrapOnboarding().finally(() => setBooting(false));
|
||||
void workspaceStore.bootstrapOnboarding();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1895,7 +2107,7 @@ export default function App() {
|
||||
setCreateWorkspaceOpen: workspaceStore.setCreateWorkspaceOpen,
|
||||
createWorkspaceFlow: workspaceStore.createWorkspaceFlow,
|
||||
pickWorkspaceFolder: workspaceStore.pickWorkspaceFolder,
|
||||
workspaceSessionGroups: sidebarWorkspaceGroups(),
|
||||
workspaceSessionGroups: hydratedSidebarWorkspaceGroups(),
|
||||
selectedSessionId: activeSessionId(),
|
||||
openRenameWorkspace: workspaceStore.openRenameWorkspace,
|
||||
editWorkspaceConnection: workspaceStore.openWorkspaceConnectionSettings,
|
||||
@@ -2012,6 +2224,10 @@ export default function App() {
|
||||
orchestratorStatus: orchestratorStatusState(),
|
||||
opencodeRouterInfo: opencodeRouterInfoState(),
|
||||
appVersion: appVersion(),
|
||||
booting: booting(),
|
||||
startupPhase: bootPhase(),
|
||||
startupBranch: startupBranch(),
|
||||
startupTrace: startupTrace(),
|
||||
headerStatus: headerStatus(),
|
||||
busyHint: busyHint(),
|
||||
updateStatus: updateStatus(),
|
||||
@@ -2019,7 +2235,8 @@ export default function App() {
|
||||
installUpdateAndRestart,
|
||||
skills: skills(),
|
||||
newTaskDisabled: newTaskDisabled(),
|
||||
workspaceSessionGroups: sidebarWorkspaceGroups(),
|
||||
sidebarHydratedFromCache: sidebarHydratedFromCache(),
|
||||
workspaceSessionGroups: hydratedSidebarWorkspaceGroups(),
|
||||
openRenameWorkspace: workspaceStore.openRenameWorkspace,
|
||||
messages: visibleMessages(),
|
||||
getSessionById: sessionById,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { t } from "../../../i18n";
|
||||
|
||||
type Props = {
|
||||
workspaceSessionGroups: WorkspaceSessionGroup[];
|
||||
showInitialLoading?: boolean;
|
||||
selectedWorkspaceId: string;
|
||||
developerMode: boolean;
|
||||
selectedSessionId: string | null;
|
||||
@@ -737,95 +738,113 @@ export default function WorkspaceSessionList(props: Props) {
|
||||
<div class="mt-3 px-1 pb-1">
|
||||
<div class="relative flex flex-col gap-1 pl-2.5 before:absolute before:bottom-2 before:left-0 before:top-2 before:w-[2px] before:bg-gray-3 before:content-['']">
|
||||
<Show
|
||||
when={
|
||||
group.status === "loading" &&
|
||||
group.sessions.length === 0
|
||||
}
|
||||
when={props.showInitialLoading}
|
||||
fallback={
|
||||
<Show
|
||||
when={group.sessions.length > 0}
|
||||
when={
|
||||
group.status === "loading" &&
|
||||
group.sessions.length === 0
|
||||
}
|
||||
fallback={
|
||||
<Show when={group.status === "error"}>
|
||||
<div
|
||||
class={`w-full rounded-[15px] border px-3 py-2.5 text-left text-[11px] ${
|
||||
taskLoadError().tone === "offline"
|
||||
? "border-amber-7/35 bg-amber-2/50 text-amber-11"
|
||||
: "border-red-7/35 bg-red-1/40 text-red-11"
|
||||
}`}
|
||||
title={taskLoadError().title}
|
||||
<Show
|
||||
when={group.sessions.length > 0}
|
||||
fallback={
|
||||
<Show when={group.status === "error"}>
|
||||
<div
|
||||
class={`w-full rounded-[15px] border px-3 py-2.5 text-left text-[11px] ${
|
||||
taskLoadError().tone === "offline"
|
||||
? "border-amber-7/35 bg-amber-2/50 text-amber-11"
|
||||
: "border-red-7/35 bg-red-1/40 text-red-11"
|
||||
}`}
|
||||
title={taskLoadError().title}
|
||||
>
|
||||
{taskLoadError().message}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={previewSessions(
|
||||
workspace().id,
|
||||
group.sessions,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
>
|
||||
{taskLoadError().message}
|
||||
</div>
|
||||
{(row) =>
|
||||
renderSessionRow(
|
||||
workspace().id,
|
||||
row,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={
|
||||
group.sessions.length === 0 &&
|
||||
group.status === "ready"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group/empty w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() =>
|
||||
props.onCreateTaskInWorkspace(workspace().id)
|
||||
}
|
||||
disabled={props.newTaskDisabled}
|
||||
>
|
||||
<span class="group-hover/empty:hidden">
|
||||
{t("workspace.no_tasks")}
|
||||
</span>
|
||||
<span class="hidden group-hover/empty:inline font-medium">
|
||||
{t("workspace.new_task_inline")}
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
getRootSessions(group.sessions).length >
|
||||
previewCount(workspace().id)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() =>
|
||||
showMoreSessions(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)
|
||||
}
|
||||
>
|
||||
{showMoreLabel(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={previewSessions(
|
||||
workspace().id,
|
||||
group.sessions,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
>
|
||||
{(row) =>
|
||||
renderSessionRow(
|
||||
workspace().id,
|
||||
row,
|
||||
tree,
|
||||
forcedExpandedSessionIds,
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={
|
||||
group.sessions.length === 0 &&
|
||||
group.status === "ready"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group/empty w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() =>
|
||||
props.onCreateTaskInWorkspace(workspace().id)
|
||||
}
|
||||
disabled={props.newTaskDisabled}
|
||||
>
|
||||
<span class="group-hover/empty:hidden">
|
||||
{t("workspace.no_tasks")}
|
||||
</span>
|
||||
<span class="hidden group-hover/empty:inline font-medium">
|
||||
{t("workspace.new_task_inline")}
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
getRootSessions(group.sessions).length >
|
||||
previewCount(workspace().id)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-[15px] border border-transparent px-3 py-2.5 text-left text-[11px] text-gray-10 transition-colors hover:bg-gray-2/60 hover:text-gray-11"
|
||||
onClick={() =>
|
||||
showMoreSessions(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)
|
||||
}
|
||||
>
|
||||
{showMoreLabel(
|
||||
workspace().id,
|
||||
getRootSessions(group.sessions).length,
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
<div class="w-full rounded-[15px] px-3 py-2.5 text-left text-[11px] text-gray-10">
|
||||
{t("workspace.loading_tasks")}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="w-full rounded-[15px] px-3 py-2.5 text-left text-[11px] text-gray-10">
|
||||
{t("workspace.loading_tasks")}
|
||||
<div class="space-y-2">
|
||||
<For each={[0, 1, 2]}>
|
||||
{(idx) => (
|
||||
<div class="w-full rounded-[15px] border border-dls-border/70 bg-dls-hover/30 px-3 py-2.5">
|
||||
<div
|
||||
class="h-2.5 rounded-full bg-dls-hover/80 animate-pulse"
|
||||
style={{ width: idx === 0 ? "62%" : idx === 1 ? "78%" : "54%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1118,7 +1118,10 @@ export function createSessionStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSession(sessionID: string) {
|
||||
async function selectSession(
|
||||
sessionID: string,
|
||||
selectOptions?: { skipHealthCheck?: boolean; source?: string },
|
||||
) {
|
||||
const c = options.client();
|
||||
if (!c) return;
|
||||
|
||||
@@ -1159,15 +1162,20 @@ export function createSessionStore(options: {
|
||||
const run = (async () => {
|
||||
mark("start");
|
||||
|
||||
mark("checking health");
|
||||
try {
|
||||
await withTimeout(c.global.health(), 3000, "health");
|
||||
mark("health ok");
|
||||
} catch (error) {
|
||||
mark("health FAILED", {
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
throw new Error(t("app.connection_lost", currentLocale()));
|
||||
const skipHealthCheck = selectOptions?.skipHealthCheck === true;
|
||||
if (skipHealthCheck) {
|
||||
mark("health skipped", { source: selectOptions?.source ?? "unknown" });
|
||||
} else {
|
||||
mark("checking health");
|
||||
try {
|
||||
await withTimeout(c.global.health(), 3000, "health");
|
||||
mark("health ok");
|
||||
} catch (error) {
|
||||
mark("health FAILED", {
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
throw new Error(t("app.connection_lost", currentLocale()));
|
||||
}
|
||||
}
|
||||
if (abortIfStale("selection changed after health")) return;
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
type SandboxDoctorResult,
|
||||
type WorkspaceInfo,
|
||||
} from "../lib/tauri";
|
||||
import type { BootPhase, StartupBranch } from "../lib/startup-boot";
|
||||
import { waitForHealthy, createClient, type OpencodeAuth } from "../lib/opencode";
|
||||
import type { OpencodeConnectStatus, ProviderListItem } from "../types";
|
||||
import { t, currentLocale } from "../../i18n";
|
||||
@@ -140,7 +141,7 @@ export function createWorkspaceStore(options: {
|
||||
creatingSession: () => boolean;
|
||||
readLastSessionByWorkspace?: () => Record<string, string>;
|
||||
selectedSessionId: () => string | null;
|
||||
selectSession: (id: string) => Promise<void>;
|
||||
selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise<void>;
|
||||
setBlueprintSeedMessagesBySessionId: (
|
||||
updater: (current: Record<string, BlueprintSeedMessage[]>) => Record<string, BlueprintSeedMessage[]>,
|
||||
) => void;
|
||||
@@ -164,6 +165,9 @@ export function createWorkspaceStore(options: {
|
||||
openworkEnvWorkspaceId?: string | null;
|
||||
setOpencodeConnectStatus?: (status: OpencodeConnectStatus | null) => void;
|
||||
onEngineStable?: () => void;
|
||||
onBootPhaseChange?: (phase: BootPhase, detail?: Record<string, unknown>) => void;
|
||||
onStartupBranch?: (branch: StartupBranch, detail?: Record<string, unknown>) => void;
|
||||
onStartupTrace?: (event: string, detail?: Record<string, unknown>) => void;
|
||||
engineRuntime?: () => EngineRuntime;
|
||||
developerMode: () => boolean;
|
||||
pendingInitialSessionSelection?: () => { workspaceId: string; title: string | null; readyAt: number } | null;
|
||||
@@ -747,7 +751,10 @@ export function createWorkspaceStore(options: {
|
||||
const shouldDeferInitialOpen = Boolean(pending && pending.workspaceId === workspaceId);
|
||||
if (result.openSessionId && !shouldDeferInitialOpen) {
|
||||
options.setView("session", result.openSessionId);
|
||||
await options.selectSession(result.openSessionId);
|
||||
await options.selectSession(result.openSessionId, {
|
||||
skipHealthCheck: true,
|
||||
source: "blueprint-open-session",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : safeStringify(error);
|
||||
@@ -2053,6 +2060,14 @@ export function createWorkspaceStore(options: {
|
||||
await options.loadSessions(targetRoot);
|
||||
connectMetrics.loadSessionsMs = Date.now() - sessionsAt;
|
||||
wsDebug("connect:loadSessions:done", { ms: Date.now() - sessionsAt });
|
||||
options.onBootPhaseChange?.("sessionIndexReady", {
|
||||
source: context?.reason ?? "connectToServer",
|
||||
targetRoot: targetRoot || null,
|
||||
});
|
||||
options.onStartupTrace?.("session-index-ready", {
|
||||
source: context?.reason ?? "connectToServer",
|
||||
targetRoot: targetRoot || null,
|
||||
});
|
||||
const pendingPermissionsAt = Date.now();
|
||||
await options.refreshPendingPermissions();
|
||||
connectMetrics.pendingPermissionsMs = Date.now() - pendingPermissionsAt;
|
||||
@@ -2221,7 +2236,10 @@ export function createWorkspaceStore(options: {
|
||||
options.setPendingInitialSessionSelection?.(null);
|
||||
options.setSelectedSessionId(openSessionId);
|
||||
options.setView("session", openSessionId);
|
||||
await options.selectSession(openSessionId);
|
||||
await options.selectSession(openSessionId, {
|
||||
skipHealthCheck: true,
|
||||
source: "create-workspace-open-session",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3769,31 +3787,49 @@ export function createWorkspaceStore(options: {
|
||||
if (options.selectedSessionId() === lastSessionId) return;
|
||||
options.setSelectedSessionId(lastSessionId);
|
||||
options.setView("session", lastSessionId);
|
||||
void options.selectSession(lastSessionId);
|
||||
void options.selectSession(lastSessionId, { skipHealthCheck: true, source: "restore-last-session" });
|
||||
}
|
||||
|
||||
async function bootstrapOnboarding() {
|
||||
const enterPhase = (phase: BootPhase, detail?: Record<string, unknown>) => {
|
||||
options.onBootPhaseChange?.(phase, detail);
|
||||
options.onStartupTrace?.(`phase:${phase}`, detail);
|
||||
};
|
||||
const markBranch = (branch: StartupBranch, detail?: Record<string, unknown>) => {
|
||||
options.onStartupBranch?.(branch, detail);
|
||||
options.onStartupTrace?.(`branch:${branch}`, detail);
|
||||
};
|
||||
|
||||
const startupPref = readStartupPreference();
|
||||
let info: EngineInfo | null = null;
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
enterPhase("workspaceBootstrap", { source: "workspace_bootstrap" });
|
||||
try {
|
||||
const ws = await workspaceBootstrap();
|
||||
setWorkspaces(ws.workspaces);
|
||||
syncSelectedWorkspaceId(pickSelectedWorkspaceId(ws.workspaces, [resolveWorkspaceListSelectedId(ws)], ws));
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error) {
|
||||
options.onStartupTrace?.("workspace_bootstrap:error", {
|
||||
error: error instanceof Error ? error.message : safeStringify(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await refreshEngine();
|
||||
await refreshEngineDoctor();
|
||||
enterPhase("engineProbe", { source: "ts-probe" });
|
||||
void refreshEngine().catch(() => undefined);
|
||||
info = engine();
|
||||
void refreshEngineDoctor().catch(() => undefined);
|
||||
|
||||
if (isTauriRuntime() && workspaces().length === 0) {
|
||||
markBranch("firstRunNoWorkspace", { startupPref });
|
||||
options.setStartupPreference("local");
|
||||
const welcomeFolder = await resolveFirstRunWelcomeFolder();
|
||||
const ok = await createWorkspaceFlow("starter", welcomeFolder);
|
||||
if (!ok) {
|
||||
options.setOnboardingStep("local");
|
||||
}
|
||||
enterPhase("ready", { reason: "first-run-no-workspace" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3826,28 +3862,35 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
const info = engine();
|
||||
if (info?.baseUrl) {
|
||||
options.setBaseUrl(info.baseUrl);
|
||||
const localEngine = info ?? engine();
|
||||
if (localEngine?.baseUrl) {
|
||||
options.setBaseUrl(localEngine.baseUrl);
|
||||
}
|
||||
|
||||
const activeWorkspace = selectedWorkspaceInfo();
|
||||
if (isTauriRuntime() && !info?.baseUrl) {
|
||||
if (isTauriRuntime() && !localEngine?.baseUrl) {
|
||||
const firstLocalWorkspace = workspaces().find((workspace) => workspace.workspaceType === "local");
|
||||
if (firstLocalWorkspace?.path?.trim()) {
|
||||
enterPhase("engineStartOrConnect", { source: "bootstrap-first-local-host-start" });
|
||||
await startHost({ workspacePath: firstLocalWorkspace.path.trim(), navigate: false }).catch(() => false);
|
||||
info = engine();
|
||||
}
|
||||
}
|
||||
|
||||
if (activeWorkspace?.workspaceType === "remote") {
|
||||
markBranch("remoteWorkspace", { workspaceId: activeWorkspace.id });
|
||||
options.setStartupPreference("server");
|
||||
options.setOnboardingStep("connecting");
|
||||
enterPhase("engineStartOrConnect", { source: "remote-activate" });
|
||||
const ok = await activateWorkspace(activeWorkspace.id);
|
||||
if (!ok) {
|
||||
options.setOnboardingStep("server");
|
||||
} else {
|
||||
enterPhase("sessionIndexReady", { source: "remote-activate" });
|
||||
restoreLastSession();
|
||||
enterPhase("firstSessionReady", { source: "restore-last-session" });
|
||||
}
|
||||
enterPhase("ready", { reason: "remote-workspace-branch" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3856,18 +3899,24 @@ export function createWorkspaceStore(options: {
|
||||
}
|
||||
|
||||
if (startupPref === "server") {
|
||||
markBranch("serverPreference", { startupPref });
|
||||
options.setOnboardingStep("server");
|
||||
enterPhase("ready", { reason: "server-preference" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkspacePath().trim()) {
|
||||
options.setStartupPreference("local");
|
||||
|
||||
if (info?.running && info.baseUrl) {
|
||||
const bootstrapRoot = selectedWorkspacePath().trim() || info.projectDir?.trim() || "";
|
||||
if (localEngine?.running && localEngine.baseUrl) {
|
||||
markBranch("localAttachExisting", {
|
||||
baseUrl: localEngine.baseUrl,
|
||||
});
|
||||
const bootstrapRoot = selectedWorkspacePath().trim() || localEngine.projectDir?.trim() || "";
|
||||
options.setOnboardingStep("connecting");
|
||||
enterPhase("engineStartOrConnect", { source: "bootstrap-local-attach" });
|
||||
const ok = await connectToServer(
|
||||
info.baseUrl,
|
||||
localEngine.baseUrl,
|
||||
bootstrapRoot || undefined,
|
||||
{ workspaceType: "local", targetRoot: bootstrapRoot, reason: "bootstrap-local" },
|
||||
engineAuth() ?? undefined,
|
||||
@@ -3875,28 +3924,42 @@ export function createWorkspaceStore(options: {
|
||||
if (!ok) {
|
||||
options.setStartupPreference(null);
|
||||
options.setOnboardingStep("welcome");
|
||||
enterPhase("error", { reason: "bootstrap-local-connect-failed" });
|
||||
return;
|
||||
}
|
||||
enterPhase("sessionIndexReady", { source: "bootstrap-local-attach" });
|
||||
restoreLastSession();
|
||||
enterPhase("firstSessionReady", { source: "restore-last-session" });
|
||||
enterPhase("ready", { reason: "bootstrap-local-attach" });
|
||||
return;
|
||||
}
|
||||
|
||||
markBranch("localHostStart", { workspacePath: selectedWorkspacePath().trim() });
|
||||
options.setOnboardingStep("connecting");
|
||||
enterPhase("engineStartOrConnect", { source: "bootstrap-local-host-start" });
|
||||
const ok = await startHost({ workspacePath: selectedWorkspacePath().trim() });
|
||||
if (!ok) {
|
||||
options.setOnboardingStep("local");
|
||||
enterPhase("error", { reason: "bootstrap-local-host-start-failed" });
|
||||
return;
|
||||
}
|
||||
enterPhase("sessionIndexReady", { source: "bootstrap-local-host-start" });
|
||||
restoreLastSession();
|
||||
enterPhase("firstSessionReady", { source: "restore-last-session" });
|
||||
enterPhase("ready", { reason: "bootstrap-local-host-start" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (startupPref === "local") {
|
||||
markBranch("localPreference", { startupPref });
|
||||
options.setOnboardingStep("local");
|
||||
enterPhase("ready", { reason: "local-preference" });
|
||||
return;
|
||||
}
|
||||
|
||||
markBranch("welcome", { startupPref: startupPref ?? null });
|
||||
options.setOnboardingStep("welcome");
|
||||
enterPhase("ready", { reason: "default-welcome" });
|
||||
}
|
||||
|
||||
function onSelectStartup(nextPref: StartupPreference) {
|
||||
|
||||
56
apps/app/src/app/lib/startup-boot.ts
Normal file
56
apps/app/src/app/lib/startup-boot.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type BootPhase =
|
||||
| "nativeInit"
|
||||
| "workspaceBootstrap"
|
||||
| "engineProbe"
|
||||
| "engineStartOrConnect"
|
||||
| "sessionIndexReady"
|
||||
| "firstSessionReady"
|
||||
| "ready"
|
||||
| "error";
|
||||
|
||||
export type StartupBranch =
|
||||
| "firstRunNoWorkspace"
|
||||
| "remoteWorkspace"
|
||||
| "localAttachExisting"
|
||||
| "localHostStart"
|
||||
| "serverPreference"
|
||||
| "localPreference"
|
||||
| "welcome"
|
||||
| "unknown";
|
||||
|
||||
export type StartupTraceEvent = {
|
||||
at: number;
|
||||
phase: BootPhase;
|
||||
event: string;
|
||||
detail?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function classifyStartupBranch(input: {
|
||||
workspaceCount: number;
|
||||
activeWorkspaceType: "local" | "remote" | null;
|
||||
startupPreference: "local" | "server" | null;
|
||||
engineHasBaseUrl: boolean;
|
||||
selectedWorkspacePath: string;
|
||||
}): StartupBranch {
|
||||
if (input.workspaceCount === 0) return "firstRunNoWorkspace";
|
||||
if (input.activeWorkspaceType === "remote") return "remoteWorkspace";
|
||||
if (input.startupPreference === "server") return "serverPreference";
|
||||
if (!input.selectedWorkspacePath.trim()) {
|
||||
if (input.startupPreference === "local") return "localPreference";
|
||||
return "welcome";
|
||||
}
|
||||
return input.engineHasBaseUrl ? "localAttachExisting" : "localHostStart";
|
||||
}
|
||||
|
||||
export function pushStartupTraceEvent(
|
||||
current: StartupTraceEvent[],
|
||||
event: StartupTraceEvent,
|
||||
maxEvents = 100,
|
||||
): StartupTraceEvent[] {
|
||||
if (!Number.isFinite(event.at) || !event.phase || !event.event) {
|
||||
return current;
|
||||
}
|
||||
const base = current.length >= maxEvents ? current.slice(current.length - maxEvents + 1) : current.slice();
|
||||
base.push(event);
|
||||
return base;
|
||||
}
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
saveSessionDraft,
|
||||
sessionDraftScopeKey,
|
||||
} from "../session/draft-store";
|
||||
import type { BootPhase, StartupBranch, StartupTraceEvent } from "../lib/startup-boot";
|
||||
import { ReactIsland } from "../../react/island";
|
||||
import { reactSessionEnabled } from "../../react/feature-flag";
|
||||
import { SessionSurface } from "../../react/session/session-surface.react";
|
||||
@@ -152,6 +153,10 @@ export type SessionViewProps = {
|
||||
orchestratorStatus: OrchestratorStatus | null;
|
||||
opencodeRouterInfo: OpenCodeRouterInfo | null;
|
||||
appVersion: string | null;
|
||||
booting: boolean;
|
||||
startupPhase: BootPhase;
|
||||
startupBranch: StartupBranch;
|
||||
startupTrace: StartupTraceEvent[];
|
||||
headerStatus: string;
|
||||
busyHint: string | null;
|
||||
updateStatus: {
|
||||
@@ -167,6 +172,7 @@ export type SessionViewProps = {
|
||||
anyActiveRuns: boolean;
|
||||
installUpdateAndRestart: () => void;
|
||||
newTaskDisabled: boolean;
|
||||
sidebarHydratedFromCache: boolean;
|
||||
workspaceSessionGroups: WorkspaceSessionGroup[];
|
||||
openRenameWorkspace: (workspaceId: string) => void;
|
||||
messages: MessageWithParts[];
|
||||
@@ -2167,6 +2173,25 @@ export default function SessionView(props: SessionViewProps) {
|
||||
if (props.messages.length > 0) return false;
|
||||
return props.sessionLoadingById(sessionId);
|
||||
});
|
||||
const showStartupSkeleton = createMemo(() => {
|
||||
if (props.messages.length > 0) return false;
|
||||
if (props.clientConnected) return false;
|
||||
const phase = props.startupPhase;
|
||||
return phase !== "sessionIndexReady" && phase !== "firstSessionReady" && phase !== "ready";
|
||||
});
|
||||
const showSidebarInitialLoading = createMemo(() => {
|
||||
if (props.workspaceSessionGroups.some((group) => group.sessions.length > 0)) {
|
||||
return false;
|
||||
}
|
||||
if (props.sidebarHydratedFromCache) return false;
|
||||
const phase = props.startupPhase;
|
||||
if (phase !== "sessionIndexReady" && phase !== "firstSessionReady" && phase !== "ready") {
|
||||
return true;
|
||||
}
|
||||
return props.workspaceSessionGroups.some(
|
||||
(group) => group.status === "loading" || group.status === "idle",
|
||||
);
|
||||
});
|
||||
const [showDelayedSessionLoadingState, setShowDelayedSessionLoadingState] = createSignal(false);
|
||||
const [deferSessionRender, setDeferSessionRender] = createSignal(false);
|
||||
let deferSessionRenderFrame: number | undefined;
|
||||
@@ -2968,6 +2993,7 @@ export default function SessionView(props: SessionViewProps) {
|
||||
selectedWorkspaceId={props.selectedWorkspaceId}
|
||||
developerMode={props.developerMode}
|
||||
selectedSessionId={props.selectedSessionId}
|
||||
showInitialLoading={showSidebarInitialLoading()}
|
||||
showSessionActions
|
||||
sessionStatusById={props.sessionStatusById}
|
||||
connectingWorkspaceId={props.connectingWorkspaceId}
|
||||
@@ -3227,6 +3253,29 @@ export default function SessionView(props: SessionViewProps) {
|
||||
chatContentEl = el;
|
||||
}}
|
||||
>
|
||||
<Show when={showStartupSkeleton()}>
|
||||
<div class="px-6 py-14" role="status" aria-live="polite">
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 w-32 rounded-full bg-dls-hover/80 animate-pulse" />
|
||||
<div class="h-3 w-64 rounded-full bg-dls-hover/60 animate-pulse" />
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<For each={[0, 1, 2]}>
|
||||
{(idx) => (
|
||||
<div class="rounded-2xl border border-dls-border bg-dls-hover/40 p-4">
|
||||
<div class="mb-3 h-3 rounded-full bg-dls-hover/80 animate-pulse" style={{ width: idx === 0 ? "42%" : idx === 1 ? "56%" : "36%" }} />
|
||||
<div class="space-y-2">
|
||||
<div class="h-2.5 rounded-full bg-dls-hover/70 animate-pulse" />
|
||||
<div class="h-2.5 rounded-full bg-dls-hover/60 animate-pulse" style={{ width: idx === 2 ? "74%" : "88%" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showDelayedSessionLoadingState()}>
|
||||
<div class="px-6 py-24">
|
||||
<div
|
||||
@@ -3252,6 +3301,7 @@ export default function SessionView(props: SessionViewProps) {
|
||||
when={
|
||||
props.messages.length === 0 &&
|
||||
!showWorkspaceSetupEmptyState() &&
|
||||
!showStartupSkeleton() &&
|
||||
!showSessionLoadingState() &&
|
||||
!deferSessionRender() &&
|
||||
!showReactSessionSurface()
|
||||
|
||||
@@ -73,7 +73,7 @@ export function createSessionActionsStore(options: {
|
||||
selectedWorkspaceRoot: () => string;
|
||||
runtimeWorkspaceRoot: () => string;
|
||||
ensureWorkspaceRuntime: (workspaceId: string) => Promise<boolean>;
|
||||
selectSession: (id: string) => Promise<void>;
|
||||
selectSession: (id: string, options?: { skipHealthCheck?: boolean; source?: string }) => Promise<void>;
|
||||
refreshSidebarWorkspaceSessions: (workspaceId: string) => Promise<void>;
|
||||
abortRefreshes: () => void;
|
||||
modelConfig: ReturnType<typeof createModelConfigStore>;
|
||||
@@ -362,7 +362,7 @@ export function createSessionActionsStore(options: {
|
||||
|
||||
options.setBusyLabel("status.loading_session");
|
||||
mark("session:select:start", { sessionID: session.id });
|
||||
await options.selectSession(session.id);
|
||||
await options.selectSession(session.id, { skipHealthCheck: true, source: "create-ready-session" });
|
||||
mark("session:select:ok", { sessionID: session.id });
|
||||
|
||||
options.modelConfig.applyPendingSessionChoice(session.id);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2715,22 +2715,22 @@
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2751,16 +2751,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
|
||||
@@ -2715,22 +2715,22 @@
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2751,16 +2751,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
|
||||
@@ -114,8 +114,13 @@ pub fn engine_info(
|
||||
manager: State<EngineManager>,
|
||||
orchestrator_manager: State<OrchestratorManager>,
|
||||
) -> EngineInfo {
|
||||
let mut state = manager.inner.lock().expect("engine mutex poisoned");
|
||||
let state = manager.inner.lock().expect("engine mutex poisoned");
|
||||
if state.runtime == EngineRuntime::Orchestrator {
|
||||
let runtime = state.runtime.clone();
|
||||
let fallback_project_dir = state.project_dir.clone();
|
||||
let fallback_username = state.opencode_username.clone();
|
||||
let fallback_password = state.opencode_password.clone();
|
||||
drop(state);
|
||||
let data_dir = orchestrator_manager
|
||||
.inner
|
||||
.lock()
|
||||
@@ -142,18 +147,18 @@ pub fn engine_info(
|
||||
.as_ref()
|
||||
.and_then(|active| status.workspaces.iter().find(|ws| &ws.id == active))
|
||||
.map(|ws| ws.path.clone())
|
||||
.or_else(|| state.project_dir.clone());
|
||||
.or(fallback_project_dir.clone());
|
||||
|
||||
// The orchestrator can keep running across app relaunches. In that case, the in-memory
|
||||
// EngineManager state (including opencode basic auth) is lost. Persist a small
|
||||
// auth snapshot next to openwork-orchestrator-state.json so the UI can reconnect.
|
||||
let auth_snapshot = orchestrator::read_orchestrator_auth(&data_dir);
|
||||
let opencode_username = state.opencode_username.clone().or_else(|| {
|
||||
let opencode_username = fallback_username.or_else(|| {
|
||||
auth_snapshot
|
||||
.as_ref()
|
||||
.and_then(|auth| auth.opencode_username.clone())
|
||||
});
|
||||
let opencode_password = state.opencode_password.clone().or_else(|| {
|
||||
let opencode_password = fallback_password.or_else(|| {
|
||||
auth_snapshot
|
||||
.as_ref()
|
||||
.and_then(|auth| auth.opencode_password.clone())
|
||||
@@ -161,7 +166,7 @@ pub fn engine_info(
|
||||
let project_dir = project_dir.or_else(|| auth_snapshot.and_then(|auth| auth.project_dir));
|
||||
return EngineInfo {
|
||||
running: status.running,
|
||||
runtime: state.runtime.clone(),
|
||||
runtime,
|
||||
base_url,
|
||||
project_dir,
|
||||
hostname: Some("127.0.0.1".to_string()),
|
||||
@@ -173,6 +178,8 @@ pub fn engine_info(
|
||||
last_stderr,
|
||||
};
|
||||
}
|
||||
drop(state);
|
||||
let mut state = manager.inner.lock().expect("engine mutex poisoned");
|
||||
EngineManager::snapshot_locked(&mut state)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ use crate::types::{
|
||||
};
|
||||
use crate::workspace::files::ensure_workspace_files;
|
||||
use crate::workspace::state::{
|
||||
load_workspace_state, normalize_local_workspace_path, save_workspace_state,
|
||||
load_workspace_state, load_workspace_state_fast, normalize_local_workspace_path,
|
||||
save_workspace_state,
|
||||
stable_workspace_id, stable_workspace_id_for_openwork, stable_workspace_id_for_remote,
|
||||
};
|
||||
use crate::workspace::watch::{update_workspace_watch, WorkspaceWatchState};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use tauri::{Manager, State};
|
||||
use walkdir::WalkDir;
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||
@@ -43,6 +44,16 @@ fn update_watched_workspace(
|
||||
update_workspace_watch(app, watch_state, watched_workspace)
|
||||
}
|
||||
|
||||
fn schedule_watched_workspace_update(app: &tauri::AppHandle, state: crate::types::WorkspaceState) {
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let watch_state = app_handle.state::<WorkspaceWatchState>();
|
||||
if let Err(error) = update_watched_workspace(&app_handle, watch_state, &state) {
|
||||
eprintln!("[workspace] deferred watcher update failed: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn workspace_bootstrap(
|
||||
app: tauri::AppHandle,
|
||||
@@ -72,7 +83,8 @@ pub fn workspace_bootstrap(
|
||||
}
|
||||
|
||||
save_workspace_state(&app, &state)?;
|
||||
update_watched_workspace(&app, watch_state, &state)?;
|
||||
let _ = watch_state;
|
||||
schedule_watched_workspace_update(&app, state.clone());
|
||||
|
||||
Ok(build_workspace_list(state))
|
||||
}
|
||||
@@ -128,7 +140,7 @@ pub fn workspace_set_selected(
|
||||
watch_state: State<WorkspaceWatchState>,
|
||||
) -> Result<WorkspaceList, String> {
|
||||
println!("[workspace] set_selected request: {workspace_id}");
|
||||
let mut state = load_workspace_state(&app)?;
|
||||
let mut state = load_workspace_state_fast(&app)?;
|
||||
let id = workspace_id.trim();
|
||||
|
||||
if id.is_empty() {
|
||||
@@ -155,7 +167,7 @@ pub fn workspace_set_runtime_active(
|
||||
watch_state: State<WorkspaceWatchState>,
|
||||
) -> Result<WorkspaceList, String> {
|
||||
println!("[workspace] set_runtime_active request: {workspace_id}");
|
||||
let mut state = load_workspace_state(&app)?;
|
||||
let mut state = load_workspace_state_fast(&app)?;
|
||||
let id = workspace_id.trim();
|
||||
|
||||
if id.is_empty() {
|
||||
@@ -168,7 +180,8 @@ pub fn workspace_set_runtime_active(
|
||||
}
|
||||
|
||||
save_workspace_state(&app, &state)?;
|
||||
update_watched_workspace(&app, watch_state, &state)?;
|
||||
let _ = watch_state;
|
||||
schedule_watched_workspace_update(&app, state.clone());
|
||||
println!(
|
||||
"[workspace] set_runtime_active complete: {}",
|
||||
if id.is_empty() { "(cleared)" } else { id }
|
||||
|
||||
@@ -46,8 +46,8 @@ use commands::window::set_window_decorations;
|
||||
use commands::workspace::{
|
||||
workspace_add_authorized_root, workspace_bootstrap, workspace_create, workspace_create_remote,
|
||||
workspace_export_config, workspace_forget, workspace_import_config, workspace_openwork_read,
|
||||
workspace_openwork_write, workspace_set_active, workspace_set_runtime_active,
|
||||
workspace_set_selected, workspace_update_display_name, workspace_update_remote,
|
||||
workspace_openwork_write, workspace_set_active, workspace_set_runtime_active, workspace_set_selected,
|
||||
workspace_update_display_name, workspace_update_remote,
|
||||
};
|
||||
use engine::manager::EngineManager;
|
||||
use opencode_router::manager::OpenCodeRouterManager;
|
||||
|
||||
@@ -158,14 +158,46 @@ fn fetch_json<T: DeserializeOwned>(url: &str) -> Result<T, String> {
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
fn fetch_json_with_timeout<T: DeserializeOwned>(url: &str, timeout_ms: u64) -> Result<T, String> {
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms.max(50));
|
||||
let agent = ureq::AgentBuilder::new().timeout(timeout).build();
|
||||
let response = agent
|
||||
.get(url)
|
||||
.set("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
response
|
||||
.into_json::<T>()
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
pub fn fetch_orchestrator_health(base_url: &str) -> Result<OrchestratorHealth, String> {
|
||||
let url = format!("{}/health", base_url.trim_end_matches('/'));
|
||||
fetch_json(&url)
|
||||
}
|
||||
|
||||
pub fn fetch_orchestrator_workspaces(base_url: &str) -> Result<OrchestratorWorkspaceList, String> {
|
||||
fn fetch_orchestrator_health_with_timeout(
|
||||
base_url: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<OrchestratorHealth, String> {
|
||||
let url = format!("{}/health", base_url.trim_end_matches('/'));
|
||||
fetch_json_with_timeout(&url, timeout_ms)
|
||||
}
|
||||
|
||||
fn fetch_orchestrator_workspaces_with_timeout(
|
||||
base_url: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<OrchestratorWorkspaceList, String> {
|
||||
let url = format!("{}/workspaces", base_url.trim_end_matches('/'));
|
||||
fetch_json(&url)
|
||||
fetch_json_with_timeout(&url, timeout_ms)
|
||||
}
|
||||
|
||||
fn resolve_orchestrator_status_timeout_ms() -> u64 {
|
||||
std::env::var("OPENWORK_ORCHESTRATOR_STATUS_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.filter(|value| *value >= 50 && *value <= 5_000)
|
||||
.unwrap_or(250)
|
||||
}
|
||||
|
||||
pub fn wait_for_orchestrator(
|
||||
@@ -397,9 +429,10 @@ pub fn resolve_orchestrator_status(
|
||||
return fallback;
|
||||
};
|
||||
|
||||
match fetch_orchestrator_health(&base_url) {
|
||||
let timeout_ms = resolve_orchestrator_status_timeout_ms();
|
||||
match fetch_orchestrator_health_with_timeout(&base_url, timeout_ms) {
|
||||
Ok(health) => {
|
||||
let workspace_payload = fetch_orchestrator_workspaces(&base_url).ok();
|
||||
let workspace_payload = fetch_orchestrator_workspaces_with_timeout(&base_url, timeout_ms).ok();
|
||||
let workspaces = workspace_payload
|
||||
.as_ref()
|
||||
.map(|payload| payload.workspaces.clone())
|
||||
|
||||
@@ -36,6 +36,28 @@ pub fn normalize_local_workspace_path(path: &str) -> String {
|
||||
normalized.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_local_workspace_path_fast(path: &str) -> String {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let expanded = if trimmed == "~" {
|
||||
home_dir().unwrap_or_else(|| PathBuf::from(trimmed))
|
||||
} else if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
|
||||
if let Some(home) = home_dir() {
|
||||
let suffix = trimmed[2..].trim_start_matches(['/', '\\']);
|
||||
home.join(suffix)
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
};
|
||||
|
||||
expanded.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn openwork_state_paths(app: &tauri::AppHandle) -> Result<(PathBuf, PathBuf), String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
@@ -52,7 +74,16 @@ pub fn repair_workspace_state(state: &mut WorkspaceState) {
|
||||
for workspace in state.workspaces.iter_mut() {
|
||||
let next_id = match workspace.workspace_type {
|
||||
WorkspaceType::Local => {
|
||||
let normalized = normalize_local_workspace_path(&workspace.path);
|
||||
// Canonicalize only currently selected/watched entries. Full canonicalization across
|
||||
// every workspace can block startup/switch paths when mounts are slow.
|
||||
let canonicalize_for_active_workspace =
|
||||
workspace.id == old_selected_workspace_id
|
||||
|| workspace.id == old_watched_workspace_id;
|
||||
let normalized = if canonicalize_for_active_workspace {
|
||||
normalize_local_workspace_path(&workspace.path)
|
||||
} else {
|
||||
normalize_local_workspace_path_fast(&workspace.path)
|
||||
};
|
||||
if !normalized.is_empty() {
|
||||
workspace.path = normalized;
|
||||
}
|
||||
@@ -126,6 +157,17 @@ pub fn load_workspace_state(app: &tauri::AppHandle) -> Result<WorkspaceState, St
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn load_workspace_state_fast(app: &tauri::AppHandle) -> Result<WorkspaceState, String> {
|
||||
let (_, path) = openwork_state_paths(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(WorkspaceState::default());
|
||||
}
|
||||
|
||||
let raw =
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save_workspace_state(app: &tauri::AppHandle, state: &WorkspaceState) -> Result<(), String> {
|
||||
let (dir, path) = openwork_state_paths(app)?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
|
||||
|
||||
Reference in New Issue
Block a user