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 { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context"; import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-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 { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events"; import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -73,7 +73,10 @@ import {
} from "@/runtime/host-runtime"; } from "@/runtime/host-runtime";
import { import {
addBrowserActiveWorkspaceLocationListener, addBrowserActiveWorkspaceLocationListener,
getLastNavigationWorkspaceRouteSelection,
hydrateLastNavigationWorkspaceRouteSelection,
syncNavigationActiveWorkspace, syncNavigationActiveWorkspace,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -103,14 +106,21 @@ export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error"; phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null; error: string | null;
retry: () => void; retry: () => void;
startupNavigation: HostRuntimeStartupNavigation | null;
} }
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon", phase: "starting-daemon",
error: null, error: null,
retry: () => {}, retry: () => {},
startupNavigation: null,
}); });
export interface HostRuntimeStartupNavigation {
target: StartupNavigationTarget;
workspaceSelection: ActiveWorkspaceSelection | null;
}
function PushNotificationRouter() { function PushNotificationRouter() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -272,33 +282,49 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0); const [retryToken, setRetryToken] = useState(0);
const [startupNavigation, setStartupNavigation] = useState<HostRuntimeStartupNavigation | null>(
null,
);
const retry = useCallback(() => { const retry = useCallback(() => {
setPhase("starting-daemon"); setPhase("starting-daemon");
setError(null); setError(null);
setStartupNavigation(null);
setRetryToken((current) => current + 1); setRetryToken((current) => current + 1);
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let cancelInitialization: (() => void) | null = null; let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null;
const abortController = new AbortController();
const shouldManageDesktop = shouldUseDesktopDaemon(); const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
setStartupNavigation(null);
const loadStartupWorkspaceSelection = async (): Promise<ActiveWorkspaceSelection | null> => {
await hydrateLastNavigationWorkspaceRouteSelection();
startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection();
return startupWorkspaceSelection;
};
void initializeHostRuntime({ void initializeHostRuntime({
shouldManageDesktop, shouldManageDesktop,
loadSettings: loadSettingsFromStorage, loadSettings: loadSettingsFromStorage,
loadStartupWorkspaceSelection,
store, store,
setPhase, setPhase,
setError, setError,
isCancelled: () => cancelled, isCancelled: () => cancelled,
signal: abortController.signal,
}) })
.then((cleanup) => { .then((target) => {
if (cancelled) { if (cancelled) {
cleanup(); return undefined;
return cleanup;
} }
cancelInitialization = cleanup; setStartupNavigation({
return cleanup; target,
workspaceSelection: startupWorkspaceSelection,
});
return undefined;
}) })
.catch((bootstrapError) => { .catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError); console.error("[HostRuntime] Failed to initialize store", bootstrapError);
@@ -314,11 +340,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
} }
setPhase("online"); setPhase("online");
setError(null); setError(null);
setStartupNavigation({
target: null,
workspaceSelection: startupWorkspaceSelection,
});
}); });
return () => { return () => {
cancelled = true; cancelled = true;
cancelInitialization?.(); abortController.abort();
}; };
}, [retryToken]); }, [retryToken]);
@@ -327,8 +357,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
phase, phase,
error, error,
retry, retry,
startupNavigation,
}), }),
[error, phase, retry], [error, phase, retry, startupNavigation],
); );
return ( return (
@@ -1,103 +1,247 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { initializeHostRuntime } from "./host-runtime-bootstrap"; import { initializeHostRuntime } from "./host-runtime-bootstrap";
describe("initializeHostRuntime", () => { type BootstrapStore = Parameters<typeof initializeHostRuntime>[0]["store"];
it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => {
const loadFromStorage = vi.fn(async () => {}); function createSettings(input: { manageBuiltInDaemon: boolean }) {
const bootstrap = vi.fn(async () => {}); return {
const bootstrapDesktop = vi.fn(async () => ({ 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, ok: true as const,
listenAddress: "127.0.0.1:6767", listenAddress: "127.0.0.1:6767",
serverId: "srv_test", serverId: "srv_test",
hostname: "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 setPhase = vi.fn();
const setError = vi.fn(); const setError = vi.fn();
await initializeHostRuntime({ const target = await initializeHostRuntime({
shouldManageDesktop: true, shouldManageDesktop: true,
loadSettings: async () => ({ loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
theme: "auto", loadStartupWorkspaceSelection: async () => null,
sendBehavior: "interrupt", store,
manageBuiltInDaemon: false,
releaseChannel: "stable",
}),
store: {
loadFromStorage,
bootstrap,
bootstrapDesktop,
addConnectionFromListenAndWaitForOnline,
waitForAnyConnectionOnline: () => ({
promise: new Promise<{ type: "online" }>(() => {}),
cancel: vi.fn(),
}),
},
setPhase, setPhase,
setError, setError,
isCancelled: () => false, 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(setPhase).toHaveBeenLastCalledWith("online");
expect(setError).toHaveBeenLastCalledWith(null); expect(setError).toHaveBeenLastCalledWith(null);
}); });
it("does not regress the phase to connecting when anyOnline wins the race before bootstrapDesktop returns", async () => { it("waits for any saved host after non-managed connection setup before setting online", async () => {
let resolveBootstrapDesktop!: () => void; const events: string[] = [];
const bootstrapDesktopGate = new Promise<void>((resolve) => { const wait = createDeferred<{ serverId: string } | null>();
resolveBootstrapDesktop = resolve; const store = createStore({
bootstrap: vi.fn(async () => {
events.push("bootstrap");
}),
waitForAnyConnectionOnline: vi.fn(() => {
events.push("wait");
return {
promise: wait.promise,
cancel: vi.fn(),
};
}),
}); });
const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`));
let resolveAddConnection!: () => void;
const addConnectionCompleted = new Promise<void>((resolve) => {
resolveAddConnection = resolve;
});
const setPhase = vi.fn();
const setError = vi.fn();
const initPromise = initializeHostRuntime({ const initPromise = initializeHostRuntime({
shouldManageDesktop: true, shouldManageDesktop: false,
loadSettings: async () => ({ loadSettings: async () => createSettings({ manageBuiltInDaemon: false }),
theme: "auto", loadStartupWorkspaceSelection: async () => {
sendBehavior: "interrupt", events.push("read-workspace-selection");
manageBuiltInDaemon: true, return null;
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(),
}),
}, },
store,
setPhase, setPhase,
setError, setError: vi.fn(),
isCancelled: () => false, isCancelled: () => false,
}); });
await initPromise; await vi.waitFor(() => {
expect(setPhase).toHaveBeenLastCalledWith("online"); expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]);
});
expect(setPhase).not.toHaveBeenCalledWith("online");
resolveBootstrapDesktop(); wait.resolve({ serverId: "srv_saved" });
await addConnectionCompleted; await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(setPhase).not.toHaveBeenCalledWith("connecting");
expect(setPhase).toHaveBeenLastCalledWith("online"); 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 { HostRuntimeBootstrapResult } from "@/runtime/host-runtime";
import type { Settings } from "@/hooks/use-settings"; 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"; type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error";
export type StartupNavigationTarget = { serverId: string } | null;
interface HostRuntimeBootstrapStore { interface HostRuntimeBootstrapStore {
loadFromStorage: () => Promise<void>; loadFromStorage: () => Promise<void>;
bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>; bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise<void>;
@@ -12,8 +19,8 @@ interface HostRuntimeBootstrapStore {
serverId: string; serverId: string;
hostname: string | null; hostname: string | null;
}) => Promise<unknown>; }) => Promise<unknown>;
waitForAnyConnectionOnline: () => { waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => {
promise: Promise<unknown>; promise: Promise<StartupNavigationTarget>;
cancel: () => void; cancel: () => void;
}; };
} }
@@ -21,12 +28,23 @@ interface HostRuntimeBootstrapStore {
export async function initializeHostRuntime(args: { export async function initializeHostRuntime(args: {
shouldManageDesktop: boolean; shouldManageDesktop: boolean;
loadSettings: () => Promise<Settings>; loadSettings: () => Promise<Settings>;
loadStartupWorkspaceSelection?: () => Promise<ActiveWorkspaceSelection | null>;
store: HostRuntimeBootstrapStore; store: HostRuntimeBootstrapStore;
setPhase: (phase: HostRuntimeBootstrapPhase) => void; setPhase: (phase: HostRuntimeBootstrapPhase) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
isCancelled: () => boolean; isCancelled: () => boolean;
}): Promise<() => void> { signal?: AbortSignal;
const { shouldManageDesktop, loadSettings, store, setPhase, setError, isCancelled } = args; }): Promise<StartupNavigationTarget> {
const {
shouldManageDesktop,
loadSettings,
loadStartupWorkspaceSelection = readStartupWorkspaceSelection,
store,
setPhase,
setError,
isCancelled,
signal,
} = args;
const settings = await loadSettings(); const settings = await loadSettings();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon; const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
@@ -36,63 +54,115 @@ export async function initializeHostRuntime(args: {
setPhase("connecting"); setPhase("connecting");
setError(null); setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!isCancelled()) { } else {
setPhase("online"); const setupResult = await setupDesktopManagedConnection({
setError(null); 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"); setPhase("starting-daemon");
setError(null); setError(null);
let raceSettled = false; try {
const anyOnline = store.waitForAnyConnectionOnline(); const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
const bootstrapPromise = (async (): Promise<BootstrapOutcome> => { if (!isCancelled()) {
try { setPhase("error");
const bootstrapResult = await store.bootstrapDesktop(); setError(bootstrapResult.error);
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
} }
if (!isCancelled() && !raceSettled) { return { type: "error", error: bootstrapResult.error };
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),
};
} }
})();
const onlineFromAny: Promise<BootstrapOutcome> = anyOnline.promise.then(() => ({ if (!isCancelled()) {
type: "online", setPhase("connecting");
})); }
const result = await Promise.race([onlineFromAny, bootstrapPromise]); await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
raceSettled = true; serverId: bootstrapResult.serverId,
anyOnline.cancel(); hostname: bootstrapResult.hostname,
});
if (!isCancelled()) { return { type: "online" };
if (result.type === "online") { } catch (error) {
setPhase("online"); const message = error instanceof Error ? error.message : String(error);
setError(null); if (!isCancelled()) {
} else {
setPhase("error"); 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 () => { const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId });
anyOnline.cancel();
}; 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 }; 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 React from "react";
import { usePathname, useRouter } from "expo-router"; import { Redirect, usePathname, type Href } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout"; import {
import { getHostRuntimeStore } from "@/runtime/host-runtime"; type HostRuntimeBootstrapState,
import { buildHostRootRoute } from "@/utils/host-routes"; useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome"; const WELCOME_ROUTE = "/welcome";
function useEarliestOnlineHostServerId(): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getEarliestOnlineHostServerId(),
() => null,
);
}
const isDesktop = shouldUseDesktopDaemon(); const isDesktop = shouldUseDesktopDaemon();
export default function Index() { export default function Index() {
const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState(); const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady(); const storeReady = useStoreReady();
const anyOnlineServerId = useEarliestOnlineHostServerId(); const redirectRoute = resolveStartupRedirectRoute({
bootstrapState,
pathname,
storeReady,
});
useEffect(() => { if (redirectRoute) {
if (!storeReady) { return <Redirect href={redirectRoute} />;
return; }
}
if (pathname !== "/" && pathname !== "") {
return;
}
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />; 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); ).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( function makeProbeMap(
entries: Array< entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never] [string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
@@ -1158,6 +1224,113 @@ describe("HostRuntimeController", () => {
}); });
describe("HostRuntimeStore", () => { 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 () => { it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({ const host = makeHost({
connections: [ connections: [
+92 -15
View File
@@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot {
clientGeneration: number; clientGeneration: number;
} }
export interface StartupHostResolution {
serverId: string;
}
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>; type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>( 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_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000; 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"; const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore { export class HostRuntimeStore {
@@ -1753,30 +1759,101 @@ export class HostRuntimeStore {
return earliestServerId; 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 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) => { const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
if (this.getEarliestOnlineHostServerId() !== null) { const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
resolve();
return; const cleanup = (): void => {
if (graceTimeout) {
clearTimeout(graceTimeout);
graceTimeout = null;
} }
if (noHostOnlineTimeout) {
clearTimeout(noHostOnlineTimeout);
noHostOnlineTimeout = null;
}
unsubscribe?.();
unsubscribe = null;
};
unsubscribe = this.subscribeAll(() => { const promise = new Promise<StartupHostResolution | null>((resolve) => {
if (this.getEarliestOnlineHostServerId() !== null) { const settle = (result: StartupHostResolution | null): void => {
unsubscribe?.(); if (settled) {
unsubscribe = null; return;
resolve();
} }
}); 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 { return {
promise, promise,
cancel: () => { cancel: cleanup,
unsubscribe?.();
unsubscribe = null;
},
}; };
} }
@@ -1,9 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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", () => ({ vi.mock("@/constants/platform", () => ({
isWeb: true, 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) { function installWindowStub(pathname: string) {
const windowStub = { const windowStub = {
location: { location: {
@@ -75,6 +94,10 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
describe("navigation active workspace store", () => { describe("navigation active workspace store", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
}); });
afterEach(() => { afterEach(() => {
@@ -173,4 +196,58 @@ describe("navigation active workspace store", () => {
workspaceId: "workspace-a", 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 { useMemo, useSyncExternalStore } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import { import {
@@ -38,8 +39,13 @@ interface NavigationObserverRef {
} | null; } | null;
} }
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
let snapshot: ActiveWorkspaceSelection | null = null; let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: 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; let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -54,13 +60,87 @@ function getSnapshot(): ActiveWorkspaceSelection | null {
return snapshot; return snapshot;
} }
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function emitIfChanged(next: ActiveWorkspaceSelection | null) { function emitIfChanged(next: ActiveWorkspaceSelection | null) {
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) { if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
return; return;
} }
snapshot = next; snapshot = next;
for (const listener of listeners) { notifyListeners();
listener(); }
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 route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route); const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") { if (routeState.kind === "workspace") {
lastWorkspaceRouteSelection = routeState.selection; setLastWorkspaceRouteSelection(routeState.selection);
if (nextWorkspaceRouteSelectionOverride) { if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride; const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null; nextWorkspaceRouteSelectionOverride = null;
@@ -197,6 +277,22 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
return lastWorkspaceRouteSelection; 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) { export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next; nextWorkspaceRouteSelectionOverride = next;
} }
@@ -223,6 +319,14 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
} }
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
return useSyncExternalStore(
subscribe,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
getIsLastNavigationWorkspaceRouteSelectionLoaded,
);
}
export function useIsNavigationWorkspaceSelected(input: { export function useIsNavigationWorkspaceSelected(input: {
serverId: string | null; serverId: string | null;
workspaceId: string | null; workspaceId: string | null;
@@ -243,6 +347,8 @@ export function useIsNavigationWorkspaceSelected(input: {
); );
} }
void hydrateLastNavigationWorkspaceRouteSelection();
export function useIsNavigationProjectActive(input: { export function useIsNavigationProjectActive(input: {
serverId: string | null; serverId: string | null;
workspaceIds: readonly string[]; workspaceIds: readonly string[];