feat: restore last workspace on cold start

Persist the user's last active workspace and navigate directly to it
on cold start instead of always landing on the open-project screen.

- Unified bootstrap into one cross-platform wait step after
  platform-specific connection setup
- Store-level wait with workspace preference: preferred host online
  resolves immediately, grace period if a different host connects
  first, bounded 5s timeout for unreachable hosts
- index.tsx is fully declarative — reads resolved target from context,
  no useEffect/promises/refs/timers
This commit is contained in:
Mohamed Boudra
2026-04-26 11:28:46 +07:00
parent 843d9234bd
commit a2c68cc689
9 changed files with 1024 additions and 173 deletions
+40 -9
View File
@@ -48,7 +48,7 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { initializeHostRuntime } from "@/app/host-runtime-bootstrap";
import { initializeHostRuntime, type StartupNavigationTarget } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -73,7 +73,10 @@ import {
} from "@/runtime/host-runtime";
import {
addBrowserActiveWorkspaceLocationListener,
getLastNavigationWorkspaceRouteSelection,
hydrateLastNavigationWorkspaceRouteSelection,
syncNavigationActiveWorkspace,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -103,14 +106,21 @@ export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
startupNavigation: HostRuntimeStartupNavigation | null;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
startupNavigation: null,
});
export interface HostRuntimeStartupNavigation {
target: StartupNavigationTarget;
workspaceSelection: ActiveWorkspaceSelection | null;
}
function PushNotificationRouter() {
const router = useRouter();
const pathname = usePathname();
@@ -272,33 +282,49 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
);
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
null,
);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setStartupNavigation(null);
setRetryToken((current) => current + 1);
}, []);
useEffect(() => {
let cancelled = false;
let cancelInitialization: (() => void) | null = null;
let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
const abortController = new AbortController();
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
setStartupNavigation(null);
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
await hydrateLastNavigationWorkspaceRouteSelection();
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
return startupWorkspaceSelection;
};
void initializeHostRuntime({
shouldManageDesktop,
loadSettings: loadSettingsFromStorage,
loadStartupWorkspaceSelection,
store,
setPhase,
setError,
isCancelled: () => cancelled,
signal: abortController.signal,
})
.then((cleanup) => {
.then((target) => {
if (cancelled) {
cleanup();
return cleanup;
return undefined;
}
cancelInitialization = cleanup;
return cleanup;
setStartupNavigation({
target,
workspaceSelection: startupWorkspaceSelection,
});
return undefined;
})
.catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
@@ -314,11 +340,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}
setPhase("online");
setError(null);
setStartupNavigation({
target: null,
workspaceSelection: startupWorkspaceSelection,
});
});
return () => {
cancelled = true;
cancelInitialization?.();
abortController.abort();
};
}, [retryToken]);
@@ -327,8 +357,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
phase,
error,
retry,
startupNavigation,
}),
[error, phase, retry],
[error, phase, retry, startupNavigation],
);
return (
@@ -1,103 +1,247 @@
import { describe, expect, it, vi } from "vitest";
import { initializeHostRuntime } from "./host-runtime-bootstrap";
describe("initializeHostRuntime", () => {
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
const loadFromStorage = vi.fn(async () => {});
const bootstrap = vi.fn(async () => {});
const bootstrapDesktop = vi.fn(async () => ({
type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
function createSettings(input: { manageBuiltInDaemon: boolean }) {
return {
theme: "auto" as const,
sendBehavior: "interrupt" as const,
manageBuiltInDaemon: input.manageBuiltInDaemon,
releaseChannel: "stable" as const,
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function createStore(overrides: Partial<BootstrapStore> = {}): BootstrapStore {
return {
loadFromStorage: vi.fn(async () => {}),
bootstrap: vi.fn(async () => {}),
bootstrapDesktop: vi.fn(async () => ({
ok: true as const,
listenAddress: "127.0.0.1:6767",
serverId: "srv_test",
hostname: "test",
}));
const addConnectionFromListenAndWaitForOnline = vi.fn(async () => {});
})),
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {}),
waitForAnyConnectionOnline: vi.fn(() => ({
promise: Promise.resolve({ serverId: "srv_test" }),
cancel: vi.fn(),
})),
...overrides,
};
}
describe("initializeHostRuntime", () => {
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
const store = createStore();
const setPhase = vi.fn();
const setError = vi.fn();
await initializeHostRuntime({
const target = await initializeHostRuntime({
shouldManageDesktop: true,
loadSettings: async () => ({
theme: "auto",
sendBehavior: "interrupt",
manageBuiltInDaemon: false,
releaseChannel: "stable",
}),
store: {
loadFromStorage,
bootstrap,
bootstrapDesktop,
addConnectionFromListenAndWaitForOnline,
waitForAnyConnectionOnline: () => ({
promise: new Promise<{ type: "online" }>(() => {}),
cancel: vi.fn(),
}),
},
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase,
setError,
isCancelled: () => false,
});
expect(bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
expect(store.bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false });
expect(store.bootstrapDesktop).not.toHaveBeenCalled();
expect(target).toEqual({ serverId: "srv_test" });
expect(setPhase).toHaveBeenLastCalledWith("online");
expect(setError).toHaveBeenLastCalledWith(null);
});
it("does not regress the phase to connecting when anyOnline wins the race before bootstrapDesktop returns", async () => {
let resolveBootstrapDesktop!: () => void;
const bootstrapDesktopGate = new Promise<void>((resolve) => {
resolveBootstrapDesktop = resolve;
it("waits for any saved host after non-managed connection setup before setting online", async () => {
const events: string[] = [];
const wait = createDeferred<{ serverId: string } | null>();
const store = createStore({
bootstrap: vi.fn(async () => {
events.push("bootstrap");
}),
waitForAnyConnectionOnline: vi.fn(() => {
events.push("wait");
return {
promise: wait.promise,
cancel: vi.fn(),
};
}),
});
let resolveAddConnection!: () => void;
const addConnectionCompleted = new Promise<void>((resolve) => {
resolveAddConnection = resolve;
});
const setPhase = vi.fn();
const setError = vi.fn();
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
const initPromise = initializeHostRuntime({
shouldManageDesktop: true,
loadSettings: async () => ({
theme: "auto",
sendBehavior: "interrupt",
manageBuiltInDaemon: true,
releaseChannel: "stable",
}),
store: {
loadFromStorage: async () => {},
bootstrap: async () => {},
bootstrapDesktop: async () => {
await bootstrapDesktopGate;
return {
ok: true as const,
listenAddress: "127.0.0.1:6767",
serverId: "srv_test",
hostname: "test",
};
},
addConnectionFromListenAndWaitForOnline: async () => {
resolveAddConnection();
},
waitForAnyConnectionOnline: () => ({
promise: Promise.resolve(),
cancel: vi.fn(),
}),
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => {
events.push("read-workspace-selection");
return null;
},
store,
setPhase,
setError,
setError: vi.fn(),
isCancelled: () => false,
});
await initPromise;
expect(setPhase).toHaveBeenLastCalledWith("online");
await vi.waitFor(() => {
expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
});
expect(setPhase).not.toHaveBeenCalledWith("online");
resolveBootstrapDesktop();
await addConnectionCompleted;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(setPhase).not.toHaveBeenCalledWith("connecting");
wait.resolve({ serverId: "srv_saved" });
await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
expect(setPhase).toHaveBeenLastCalledWith("online");
});
it("passes the startup workspace server id into the shared wait", async () => {
const store = createStore();
await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => ({
serverId: "srv_workspace",
workspaceId: "workspace-1",
}),
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({
preferredServerId: "srv_workspace",
});
});
it("passes a null preference into the shared wait when no startup workspace is persisted", async () => {
const store = createStore();
await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
});
it("uses desktop daemon startup only as the managed connection producer before the shared wait", async () => {
const events: string[] = [];
const store = createStore({
bootstrapDesktop: vi.fn(async () => {
events.push("bootstrap-desktop");
return {
ok: true as const,
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: "desktop",
};
}),
addConnectionFromListenAndWaitForOnline: vi.fn(async () => {
events.push("add-connection");
}),
waitForAnyConnectionOnline: vi.fn(() => {
events.push("wait");
return {
promise: Promise.resolve({ serverId: "srv_desktop" }),
cancel: vi.fn(),
};
}),
});
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
const target = await initializeHostRuntime({
shouldManageDesktop: true,
loadSettings: async () => createSettings({ manageBuiltInDaemon: true }),
loadStartupWorkspaceSelection: async () => {
events.push("read-workspace-selection");
return null;
},
store,
setPhase,
setError: vi.fn(),
isCancelled: () => false,
});
expect(target).toEqual({ serverId: "srv_desktop" });
expect(events).toEqual([
"phase:starting-daemon",
"bootstrap-desktop",
"phase:connecting",
"add-connection",
"read-workspace-selection",
"wait",
"phase:online",
]);
expect(store.bootstrap).not.toHaveBeenCalled();
});
it("returns null without waiting when there are no saved hosts", async () => {
const store = createStore({
waitForAnyConnectionOnline: vi.fn(() => ({
promise: Promise.resolve(null),
cancel: vi.fn(),
})),
});
const target = await initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase: vi.fn(),
setError: vi.fn(),
isCancelled: () => false,
});
expect(target).toBeNull();
expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null });
});
it("cancels the shared wait while initialization is still pending", async () => {
const abortController = new AbortController();
const wait = createDeferred<{ serverId: string } | null>();
const cancel = vi.fn();
const store = createStore({
waitForAnyConnectionOnline: vi.fn(() => ({
promise: wait.promise,
cancel,
})),
});
const setPhase = vi.fn();
const initPromise = initializeHostRuntime({
shouldManageDesktop: false,
loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
loadStartupWorkspaceSelection: async () => null,
store,
setPhase,
setError: vi.fn(),
isCancelled: () => true,
signal: abortController.signal,
});
await vi.waitFor(() => {
expect(store.waitForAnyConnectionOnline).toHaveBeenCalled();
});
abortController.abort();
await expect(initPromise).resolves.toBeNull();
expect(cancel).toHaveBeenCalledTimes(1);
expect(setPhase).not.toHaveBeenCalledWith("online");
});
});
+118 -48
View File
@@ -1,8 +1,15 @@
import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
import type { Settings } from "@/hooks/use-settings";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
getLastNavigationWorkspaceRouteSelection,
hydrateLastNavigationWorkspaceRouteSelection,
} from "@/stores/navigation-active-workspace-store";
type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
export type StartupNavigationTarget = { serverId: string } | null;
interface HostRuntimeBootstrapStore {
loadFromStorage: () => Promise<void>;
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
@@ -12,8 +19,8 @@ interface HostRuntimeBootstrapStore {
serverId: string;
hostname: string | null;
}) => Promise<unknown>;
waitForAnyConnectionOnline: () => {
promise: Promise<unknown>;
waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
promise: Promise<StartupNavigationTarget>;
cancel: () => void;
};
}
@@ -21,12 +28,23 @@ interface HostRuntimeBootstrapStore {
export async function initializeHostRuntime(args: {
shouldManageDesktop: boolean;
loadSettings: () => Promise<Settings>;
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
store: HostRuntimeBootstrapStore;
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
setError: (error: string | null) => void;
isCancelled: () => boolean;
}): Promise<() => void> {
const { shouldManageDesktop, loadSettings, store, setPhase, setError, isCancelled } = args;
signal?: AbortSignal;
}): Promise<StartupNavigationTarget> {
const {
shouldManageDesktop,
loadSettings,
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
store,
setPhase,
setError,
isCancelled,
signal,
} = args;
const settings = await loadSettings();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
@@ -36,63 +54,115 @@ export async function initializeHostRuntime(args: {
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!isCancelled()) {
setPhase("online");
setError(null);
} else {
const setupResult = await setupDesktopManagedConnection({
store,
setPhase,
setError,
isCancelled,
});
if (setupResult.type === "error") {
return null;
}
return () => {};
}
const startupWorkspaceSelection = await loadStartupWorkspaceSelection();
const target = await waitForStartupNavigationTarget({
store,
preferredServerId: startupWorkspaceSelection?.serverId ?? null,
signal,
});
if (!isCancelled()) {
setPhase("online");
setError(null);
}
return target;
}
async function setupDesktopManagedConnection(input: {
store: HostRuntimeBootstrapStore;
setPhase: (phase: HostRuntimeBootstrapPhase) => void;
setError: (error: string | null) => void;
isCancelled: () => boolean;
}): Promise<BootstrapOutcome> {
const { store, setPhase, setError, isCancelled } = input;
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
const bootstrapPromise = (async (): Promise<BootstrapOutcome> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!isCancelled()) {
setPhase("error");
setError(bootstrapResult.error);
}
if (!isCancelled() && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (error) {
return {
type: "error",
error: error instanceof Error ? error.message : String(error),
};
return { type: "error", error: bootstrapResult.error };
}
})();
const onlineFromAny: Promise<BootstrapOutcome> = anyOnline.promise.then(() => ({
type: "online",
}));
const result = await Promise.race([onlineFromAny, bootstrapPromise]);
raceSettled = true;
anyOnline.cancel();
if (!isCancelled()) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
if (!isCancelled()) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isCancelled()) {
setPhase("error");
setError(result.error);
setError(message);
}
return { type: "error", error: message };
}
}
function waitForStartupNavigationTarget(input: {
store: HostRuntimeBootstrapStore;
preferredServerId: string | null;
signal?: AbortSignal;
}): Promise<StartupNavigationTarget> {
const { store, preferredServerId, signal } = input;
if (signal?.aborted) {
return Promise.resolve(null);
}
return () => {
anyOnline.cancel();
};
const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
return new Promise<StartupNavigationTarget>((resolve) => {
let settled = false;
const settle = (target: StartupNavigationTarget): void => {
if (settled) {
return;
}
settled = true;
signal?.removeEventListener("abort", abort);
onlineWait.cancel();
resolve(target);
};
const abort = (): void => {
settle(null);
};
signal?.addEventListener("abort", abort, { once: true });
onlineWait.promise.then((target) => {
settle(target);
return undefined;
});
});
}
type BootstrapOutcome = { type: "online" } | { type: "error"; error: string };
async function readStartupWorkspaceSelection(): Promise<ActiveWorkspaceSelection | null> {
await hydrateLastNavigationWorkspaceRouteSelection();
return getLastNavigationWorkspaceRouteSelection();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
const { redirectMock, runtimeStoreAccessMock, workspaceStoreAccessMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
storeReady: true,
bootstrapState: {
phase: "online",
error: null,
retry: vi.fn(),
startupNavigation: null,
} as HostRuntimeBootstrapState,
};
return {
redirectMock: vi.fn(),
runtimeStoreAccessMock: vi.fn(() => {
throw new Error("index.tsx must not read the host runtime store during startup navigation");
}),
workspaceStoreAccessMock: vi.fn(() => {
throw new Error(
"index.tsx must not read the navigation workspace store during startup navigation",
);
}),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useStoreReady: () => state.storeReady,
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: runtimeStoreAccessMock,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: workspaceStoreAccessMock,
useIsLastNavigationWorkspaceRouteSelectionLoaded: workspaceStoreAccessMock,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.storeReady = true;
state.bootstrapState = {
phase: "online",
error: null,
retry: vi.fn(),
startupNavigation: null,
};
redirectMock.mockReset();
runtimeStoreAccessMock.mockClear();
workspaceStoreAccessMock.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash until bootstrap has resolved the startup navigation target", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the startup target matches its host", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-1" },
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(runtimeStoreAccessMock).not.toHaveBeenCalled();
expect(workspaceStoreAccessMock).not.toHaveBeenCalled();
});
it("navigates to the startup host root when it differs from the persisted workspace host", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-2" },
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("navigates to the startup host root when no persisted workspace exists", async () => {
state.bootstrapState.startupNavigation = {
target: { serverId: "server-2" },
workspaceSelection: null,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when bootstrap resolves no startup target", async () => {
state.bootstrapState.startupNavigation = {
target: null,
workspaceSelection: null,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});
+43 -27
View File
@@ -1,42 +1,58 @@
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import React from "react";
import { Redirect, usePathname, type Href } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import {
type HostRuntimeBootstrapState,
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
function useEarliestOnlineHostServerId(): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getEarliestOnlineHostServerId(),
() => null,
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const anyOnlineServerId = useEarliestOnlineHostServerId();
const redirectRoute = resolveStartupRedirectRoute({
bootstrapState,
pathname,
storeReady,
});
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
}
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
function resolveStartupRedirectRoute(input: {
bootstrapState: HostRuntimeBootstrapState;
pathname: string;
storeReady: boolean;
}): Href | null {
const { bootstrapState, pathname, storeReady } = input;
if (!storeReady || !bootstrapState.startupNavigation) {
return null;
}
if (pathname !== "/" && pathname !== "") {
return null;
}
const { target, workspaceSelection } = bootstrapState.startupNavigation;
if (!target) {
return WELCOME_ROUTE;
}
if (workspaceSelection && target.serverId === workspaceSelection.serverId) {
return buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId);
}
return buildHostRootRoute(target.serverId);
}
@@ -282,6 +282,72 @@ function updateControllerSnapshot(
).updateSnapshot(patch);
}
function makeSingleConnectionHost(serverId: string): HostProfile {
return makeHost({
serverId,
connections: [
{
id: `direct:${serverId}:6767`,
type: "directTcp",
endpoint: `${serverId}:6767`,
},
],
preferredConnectionId: `direct:${serverId}:6767`,
});
}
function createOfflineOnlyStore(): HostRuntimeStore {
return new HostRuntimeStore({
deps: {
createClient: () => {
throw new Error("startup host tests drive snapshots directly");
},
connectToDaemon: async () => {
throw new Error("offline");
},
getClientId: async () => "cid_test_runtime",
},
});
}
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
(
store as unknown as {
setHostsAndSync: (hosts: HostProfile[]) => void;
}
).setHostsAndSync(hosts);
}
function markStoreHostOnline(
store: HostRuntimeStore,
serverId: string,
lastOnlineAt: string,
): void {
const controller = (
store as unknown as {
controllers: Map<string, HostRuntimeController>;
}
).controllers.get(serverId);
if (!controller) {
throw new Error(`Missing controller for ${serverId}`);
}
(
store as unknown as {
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
}
).lastConnectionStatusByServer.set(serverId, "online");
updateControllerSnapshot(controller, {
connectionStatus: "online",
hasEverLoadedAgentDirectory: true,
lastOnlineAt,
});
(
store as unknown as {
emit: (serverId: string) => void;
}
).emit(serverId);
}
function makeProbeMap(
entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
});
describe("HostRuntimeStore", () => {
it("resolves startup host immediately when the preferred host is first online", async () => {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
startup.cancel();
setStoreHosts(store, []);
});
it("waits through grace for the preferred host after a different host comes online first", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await vi.advanceTimersByTimeAsync(1_000);
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("falls back to the first online host when preferred does not arrive during grace", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await vi.advanceTimersByTimeAsync(2_000);
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("resolves the first online host immediately when no preferred host is set", async () => {
const store = createOfflineOnlyStore();
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
startup.cancel();
setStoreHosts(store, []);
});
it("resolves null when startup has no hosts to wait for", async () => {
const store = createOfflineOnlyStore();
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
await expect(startup.promise).resolves.toBeNull();
startup.cancel();
});
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
vi.useFakeTimers();
try {
const store = createOfflineOnlyStore();
setStoreHosts(store, [
makeSingleConnectionHost("srv_preferred"),
makeSingleConnectionHost("srv_other"),
]);
let resolved = false;
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
startup.promise.then(() => {
resolved = true;
return undefined;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(startup.promise).resolves.toBeNull();
startup.cancel();
setStoreHosts(store, []);
} finally {
vi.useRealTimers();
}
});
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
+92 -15
View File
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
clientGeneration: number;
}
export interface StartupHostResolution {
serverId: string;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
@@ -1151,6 +1155,8 @@ const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const STARTUP_HOST_GRACE_MS = 2_000;
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1753,30 +1759,101 @@ export class HostRuntimeStore {
return earliestServerId;
}
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
promise: Promise<StartupHostResolution | null>;
cancel: () => void;
} {
let unsubscribe: (() => void) | null = null;
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
let fallbackServerId: string | null = null;
let settled = false;
const promise = new Promise<void>((resolve) => {
if (this.getEarliestOnlineHostServerId() !== null) {
resolve();
return;
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
const cleanup = (): void => {
if (graceTimeout) {
clearTimeout(graceTimeout);
graceTimeout = null;
}
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
unsubscribe?.();
unsubscribe = null;
};
unsubscribe = this.subscribeAll(() => {
if (this.getEarliestOnlineHostServerId() !== null) {
unsubscribe?.();
unsubscribe = null;
resolve();
const promise = new Promise<StartupHostResolution | null>((resolve) => {
const settle = (result: StartupHostResolution | null): void => {
if (settled) {
return;
}
});
settled = true;
cleanup();
resolve(result);
};
const getPreferredOnlineServerId = (): string | null => {
if (!normalizedPreferredServerId) {
return null;
}
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
? normalizedPreferredServerId
: null;
};
const evaluate = (): void => {
if (this.hosts.length === 0) {
settle(null);
return;
}
const preferredOnlineServerId = getPreferredOnlineServerId();
if (preferredOnlineServerId) {
settle({ serverId: preferredOnlineServerId });
return;
}
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
if (!earliestOnlineServerId) {
if (!noHostOnlineTimeout) {
noHostOnlineTimeout = setTimeout(() => {
settle(null);
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
}
return;
}
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
if (!normalizedPreferredServerId) {
settle({ serverId: earliestOnlineServerId });
return;
}
if (!fallbackServerId) {
fallbackServerId = earliestOnlineServerId;
graceTimeout = setTimeout(() => {
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
settle({
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
});
}, normalizedGraceMs);
}
};
unsubscribe = this.subscribeAll(evaluate);
evaluate();
});
return {
promise,
cancel: () => {
unsubscribe?.();
unsubscribe = null;
},
cancel: cleanup,
};
}
@@ -1,9 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function installWindowStub(pathname: string) {
const windowStub = {
location: {
@@ -75,6 +94,10 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
describe("navigation active workspace store", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
});
afterEach(() => {
@@ -173,4 +196,58 @@ describe("navigation active workspace store", () => {
workspaceId: "workspace-a",
});
});
it("hydrates the last workspace route selection from storage on startup", async () => {
const storedSelection = createDeferred<string | null>();
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
installWindowStub("/open-project");
const store = await import("@/stores/navigation-active-workspace-store");
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
await hydration;
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
);
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("hydrates empty and corrupt workspace route storage as null", async () => {
installWindowStub("/open-project");
asyncStorageMock.getItem.mockResolvedValueOnce(null);
let store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
vi.resetModules();
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
});
it("persists valid last workspace route selections", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
await store.hydrateLastNavigationWorkspaceRouteSelection();
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
);
});
});
@@ -1,3 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import {
@@ -38,8 +39,13 @@ interface NavigationObserverRef {
} | null;
}
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let isLastWorkspaceRouteSelectionLoaded = false;
let lastWorkspaceRouteSelectionRevision = 0;
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
@@ -54,13 +60,87 @@ function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot;
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return;
}
snapshot = next;
for (const listener of listeners) {
listener();
notifyListeners();
}
function parseStoredLastWorkspaceRouteSelection(
stored: string | null,
): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const selection = input as Record<string, unknown>;
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
() => {},
);
}
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
const normalized = normalizeActiveWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
) {
return;
}
lastWorkspaceRouteSelectionRevision += 1;
lastWorkspaceRouteSelection = normalized;
persistLastWorkspaceRouteSelection(normalized);
}
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
try {
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
}
} catch {
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
lastWorkspaceRouteSelection = null;
}
} finally {
isLastWorkspaceRouteSelectionLoaded = true;
notifyListeners();
}
}
@@ -170,7 +250,7 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
lastWorkspaceRouteSelection = routeState.selection;
setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
@@ -197,6 +277,22 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
return lastWorkspaceRouteSelection;
}
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return isLastWorkspaceRouteSelectionLoaded;
}
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
if (lastWorkspaceRouteSelectionHydrationPromise) {
return lastWorkspaceRouteSelectionHydrationPromise;
}
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
lastWorkspaceRouteSelectionHydrationPromise =
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
return lastWorkspaceRouteSelectionHydrationPromise;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
@@ -223,6 +319,14 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null;
workspaceId: string | null;
@@ -243,6 +347,8 @@ export function useIsNavigationWorkspaceSelected(input: {
);
}
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: {
serverId: string | null;
workspaceIds: readonly string[];