From a2c68cc689117f9ba4b762cb44a9dde9430ec8d1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Apr 2026 11:28:46 +0700 Subject: [PATCH] feat: restore last workspace on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/app/src/app/_layout.tsx | 49 ++- .../src/app/host-runtime-bootstrap.test.ts | 286 +++++++++++++----- .../app/src/app/host-runtime-bootstrap.ts | 166 +++++++--- packages/app/src/app/index.test.tsx | 157 ++++++++++ packages/app/src/app/index.tsx | 70 +++-- packages/app/src/runtime/host-runtime.test.ts | 173 +++++++++++ packages/app/src/runtime/host-runtime.ts | 107 ++++++- .../navigation-active-workspace-store.test.ts | 77 +++++ .../navigation-active-workspace-store.ts | 112 ++++++- 9 files changed, 1024 insertions(+), 173 deletions(-) create mode 100644 packages/app/src/app/index.test.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index cd137bc88..5cc6bf706 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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({ 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(null); const [retryToken, setRetryToken] = useState(0); + const [startupNavigation, setStartupNavigation] = useState( + 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 => { + 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 ( diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index 05ac0ad19..71d153443 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -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[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() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function createStore(overrides: Partial = {}): 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((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((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"); + }); }); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index 9f75aefcd..a8edd81ba 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -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; bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise; @@ -12,8 +19,8 @@ interface HostRuntimeBootstrapStore { serverId: string; hostname: string | null; }) => Promise; - waitForAnyConnectionOnline: () => { - promise: Promise; + waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => { + promise: Promise; cancel: () => void; }; } @@ -21,12 +28,23 @@ interface HostRuntimeBootstrapStore { export async function initializeHostRuntime(args: { shouldManageDesktop: boolean; loadSettings: () => Promise; + loadStartupWorkspaceSelection?: () => Promise; 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 { + 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 { + const { store, setPhase, setError, isCancelled } = input; + setPhase("starting-daemon"); setError(null); - let raceSettled = false; - const anyOnline = store.waitForAnyConnectionOnline(); - - const bootstrapPromise = (async (): Promise => { - 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 = 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 { + const { store, preferredServerId, signal } = input; + + if (signal?.aborted) { + return Promise.resolve(null); } - return () => { - anyOnline.cancel(); - }; + const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId }); + + return new Promise((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 { + await hydrateLastNavigationWorkspaceRouteSelection(); + return getLastNavigationWorkspaceRouteSelection(); +} diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx new file mode 100644 index 000000000..2d9b1122b --- /dev/null +++ b/packages/app/src/app/index.test.tsx @@ -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(); + }); + } + + 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"); + }); +}); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index e221c40f9..7890aec4c 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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 ; + } return ; } + +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); +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 73a2635be..ce28accfa 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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; + } + ).controllers.get(serverId); + if (!controller) { + throw new Error(`Missing controller for ${serverId}`); + } + ( + store as unknown as { + lastConnectionStatusByServer: Map; + } + ).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 ? 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: [ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 41eda724b..6a9d2d612 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -70,6 +70,10 @@ export interface HostRuntimeSnapshot { clientGeneration: number; } +export interface StartupHostResolution { + serverId: string; +} + type HostRuntimeSnapshotPatch = Partial>; function setSnapshotPatchField( @@ -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; cancel: () => void } { + waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): { + promise: Promise; + cancel: () => void; + } { let unsubscribe: (() => void) | null = null; + let graceTimeout: ReturnType | null = null; + let noHostOnlineTimeout: ReturnType | null = null; + let fallbackServerId: string | null = null; + let settled = false; - const promise = new Promise((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((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, }; } diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts index cf9937e2d..5d2704efa 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -1,9 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn<(_: string) => Promise>(), + setItem: vi.fn<(_: string, __: string) => Promise>(), +})); + +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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + 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" }), + ); + }); }); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 1ca36d637..1546fae21 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -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 | 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; + 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 { + 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[];