From d802534e7bb5d21a989710c0e61d7423c5eb0714 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 16:08:50 +0700 Subject: [PATCH] fix(app): preserve workspace route during desktop refresh --- packages/app/e2e/global-setup.ts | 24 +- packages/app/e2e/helpers/desktop-updates.ts | 25 +- .../workspace-navigation-regression.spec.ts | 75 +++++- packages/app/src/app/h/[serverId]/_layout.tsx | 28 ++- packages/app/src/app/h/[serverId]/new.tsx | 15 +- .../src/app/host-runtime-bootstrap.test.ts | 219 ++++++++++-------- .../app/src/app/host-runtime-bootstrap.ts | 126 ++++++++-- packages/app/src/app/index.tsx | 38 +-- .../host-route-bootstrap-boundary.tsx | 4 +- packages/app/src/runtime/host-runtime.ts | 25 +- packages/app/src/utils/host-routes.test.ts | 30 --- packages/app/src/utils/host-routes.ts | 21 -- 12 files changed, 395 insertions(+), 235 deletions(-) diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index edcc84500..111e2d10b 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -533,13 +533,23 @@ async function startRelay(excludedPorts: Set): Promise { ); } -function startMetro(metroPort: number, buffer: ReturnType): ChildProcess { +function startMetro(input: { + metroPort: number; + daemonPort: number; + buffer: ReturnType; +}): ChildProcess { const appDir = path.resolve(__dirname, ".."); - const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], { + const child = spawn("npx", ["expo", "start", "--web", "--port", String(input.metroPort)], { cwd: appDir, env: { ...process.env, BROWSER: "none", + ...(process.env.E2E_DESKTOP_RUNTIME === "1" + ? { + PASEO_WEB_PLATFORM: "electron", + EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${input.daemonPort}`, + } + : {}), }, stdio: ["ignore", "pipe", "pipe"], detached: false, @@ -551,7 +561,7 @@ function startMetro(metroPort: number, buffer: ReturnType line.trim()); for (const line of lines) { - buffer.add(`[stdout] ${line}`); + input.buffer.add(`[stdout] ${line}`); console.log(`[metro] ${line}`); } }); @@ -562,7 +572,7 @@ function startMetro(metroPort: number, buffer: ReturnType line.trim()); for (const line of lines) { - buffer.add(`[stderr] ${line}`); + input.buffer.add(`[stderr] ${line}`); console.error(`[metro] ${line}`); } }); @@ -685,7 +695,11 @@ export default async function globalSetup() { try { const relayPort = await startRelay(new Set([port, metroPort])); - metroProcess = startMetro(metroPort, metroLineBuffer); + metroProcess = startMetro({ + metroPort, + daemonPort: port, + buffer: metroLineBuffer, + }); daemonProcess = startDaemon({ port, relayPort, diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 15af76323..91564b197 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -59,6 +59,10 @@ export interface DesktopBridgeConfig { daemonLogPath?: string; /** Initial manageBuiltInDaemon setting. Defaults to false. */ manageBuiltInDaemon?: boolean; + /** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */ + daemonListen?: string; + /** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */ + hangDaemonStart?: boolean; /** * Controls what dialog.ask returns when the daemon management confirm dialog * fires. True = confirm (proceed with the action), false = cancel. Defaults to @@ -124,7 +128,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi return { serverId: cfg.serverId, status: daemonRunning ? "running" : "stopped", - listen: "127.0.0.1:6767", + listen: cfg.daemonListen ?? "127.0.0.1:6767", hostname: null, pid: currentPid, home: "", @@ -134,6 +138,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi }; } + function startDesktopDaemon() { + if (cfg.hangDaemonStart) { + return new Promise(() => undefined); + } + startCount += 1; + daemonRunning = true; + // First start (bootstrap) returns the configured PID; subsequent starts + // (after a stop) get a fresh PID so tests can observe the change. + currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; + return buildDaemonStatus(); + } + const desktopBridge: { platform: string; invoke: (command: string, args?: Record) => Promise; @@ -218,12 +234,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi } if (command === "start_desktop_daemon") { - startCount += 1; - daemonRunning = true; - // First start (bootstrap) returns the configured PID; subsequent starts - // (after a stop) get a fresh PID so tests can observe the change. - currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; - return buildDaemonStatus(); + return startDesktopDaemon(); } return null; diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 7566c35de..998e816d4 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -9,7 +9,7 @@ import { openWorkspaceWithAgents, } from "./helpers/archive-tab"; import { expectComposerVisible } from "./helpers/composer"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { daemonWsRoutePattern, getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace } from "./helpers/seed-client"; import { getVisibleWorkspaceAgentTabIds, @@ -32,6 +32,7 @@ import { } from "./helpers/workspace-ui"; import { clickSettingsBackToWorkspace } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; +import { injectDesktopBridge } from "./helpers/desktop-updates"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; @@ -79,6 +80,43 @@ async function closeFirstVisibleDraftTab(page: Page): Promise { await closeButton.first().click(); } +async function openWorkspaceThroughApp( + page: Page, + input: { + serverId: string; + workspace: Awaited>; + }, +): Promise { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ + page, + serverId: input.serverId, + targetWorkspacePath: input.workspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceLocation(page, input); +} + +async function expectWorkspaceLocation( + page: Page, + input: { + serverId: string; + workspace: Awaited>; + }, +): Promise { + await expect(page).toHaveURL( + buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId), + { + timeout: 30_000, + }, + ); + await expectWorkspaceHeader(page, { + title: input.workspace.workspaceName, + subtitle: input.workspace.projectDisplayName, + }); +} + async function installDaemonWebSocketGate(page: Page) { let acceptingConnections = true; const activeSockets = new Set(); @@ -260,6 +298,41 @@ test.describe("Workspace navigation regression", () => { } }); + test("refresh keeps the user on the same workspace route", async ({ page }) => { + const serverId = getServerId(); + const daemonGate = await installDaemonWebSocketGate(page); + const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" }); + + try { + const agent = await createIdleAgent(workspace.client, { + cwd: workspace.repoPath, + title: `workspace-refresh-route-${Date.now()}`, + }); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: true, + hangDaemonStart: true, + daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, + }); + await openWorkspaceThroughApp(page, { serverId, workspace }); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); + await expectWorkspaceLocation(page, { serverId, workspace }); + + await daemonGate.drop(); + await page.reload(); + await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 }); + daemonGate.restore(); + await waitForSidebarHydration(page); + + await expectWorkspaceLocation(page, { serverId, workspace }); + await waitForWorkspaceTabsVisible(page); + } finally { + daemonGate.restore(); + await workspace.cleanup(); + } + }); + test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ page, }) => { diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index 2e6f67078..c84e1d397 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -1,25 +1,31 @@ import { Redirect, Slot, useLocalSearchParams } from "expo-router"; -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { useHosts } from "@/runtime/host-runtime"; -import { resolveKnownHostRoute } from "@/utils/host-routes"; +import { useHostRuntimeBootstrapState } from "@/app/_layout"; +import { resolveStartupRoute } from "@/app/host-runtime-bootstrap"; +import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime"; export default function HostRouteLayout() { - return ( - - - - ); + return ; } function KnownHostRoute() { const params = useLocalSearchParams<{ serverId?: string | string[] }>(); const hosts = useHosts(); + const hostRegistryStatus = useHostRegistryStatus(); + const bootstrapState = useHostRuntimeBootstrapState(); const routeServerId = typeof params.serverId === "string" ? params.serverId : null; - const resolution = resolveKnownHostRoute({ routeServerId, hosts }); + const startupRoute = resolveStartupRoute({ + route: { kind: "host", serverId: routeServerId }, + startupBlocker: bootstrapState.startupBlocker, + hostRegistryStatus, + hosts, + }); - if (resolution.kind === "redirect") { - return ; + if (startupRoute.kind === "redirect") { + return ; } + // Keep the host Slot mounted while startup gates are active. React Navigation + // web can reserialize a shallower tree and drop nested workspace URL segments + // if the layout swaps Slot for a splash; leaf routes own the splash boundary. return ; } diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx index e88504b11..c59d85be3 100644 --- a/packages/app/src/app/h/[serverId]/new.tsx +++ b/packages/app/src/app/h/[serverId]/new.tsx @@ -1,4 +1,5 @@ import { useLocalSearchParams } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; export default function HostNewWorkspaceRoute() { @@ -14,11 +15,13 @@ export default function HostNewWorkspaceRoute() { const projectId = typeof params.projectId === "string" ? params.projectId : undefined; return ( - + + + ); } diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index b861e8c7a..479ebae91 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import { resolveStartupBlocker, resolveStartupNavigationReady, - resolveStartupRedirectRoute, - resolveStartupWorkspaceSelection, + resolveStartupRoute, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, - WELCOME_ROUTE, } from "./host-runtime-bootstrap"; function createFakeStore() { @@ -194,131 +192,154 @@ describe("startup blocking policy", () => { }); }); -describe("resolveStartupRedirectRoute", () => { - const baseInput = { - pathname: "/", +describe("resolveStartupRoute", () => { + const baseIndexInput = { + route: { kind: "index" as const, pathname: "/" }, + startupBlocker: { kind: "none" as const }, + hostRegistryStatus: "ready" as const, + hosts: [], anyOnlineHostServerId: null, workspaceSelection: null, isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: false, }; + const baseHostInput = { + route: { kind: "host" as const, serverId: "server-saved" }, + startupBlocker: { kind: "none" as const }, + hostRegistryStatus: "ready" as const, + hosts: [], + }; - it("returns null when the pathname is not the index route", () => { + it("renders non-index routes instead of making an index startup decision", () => { expect( - resolveStartupRedirectRoute({ - ...baseInput, - pathname: "/h/server-1", - anyOnlineHostServerId: "server-1", + resolveStartupRoute({ + ...baseIndexInput, + route: { kind: "index", pathname: "/settings" }, }), - ).toBeNull(); + ).toEqual({ kind: "render" }); }); - it("waits while the persisted workspace selection has not finished loading", () => { + it("keeps startup on the splash while the persisted workspace selection is loading", () => { expect( - resolveStartupRedirectRoute({ - ...baseInput, + resolveStartupRoute({ + ...baseIndexInput, anyOnlineHostServerId: "server-1", isWorkspaceSelectionLoaded: false, }), - ).toBeNull(); + ).toEqual({ kind: "splash" }); }); - it("waits while no host is online and the give-up timer has not fired", () => { - expect(resolveStartupRedirectRoute(baseInput)).toBeNull(); + it("keeps startup on the splash while the host registry is loading", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hostRegistryStatus: "loading", + }), + ).toEqual({ kind: "splash" }); }); - describe("scenario: saved-host-online", () => { - it("leaves matching persisted workspace navigation to the workspace navigator", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "server-1", + it("does not treat loading hosts as an empty registry when a workspace is already restored", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hostRegistryStatus: "loading", workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }); - - expect(route).toBeNull(); - }); - - it("resolves the persisted workspace when the online host matches it", () => { - const selection = resolveStartupWorkspaceSelection({ - ...baseInput, - anyOnlineHostServerId: "server-1", - workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }); - - expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); - }); - - it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "server-2", - workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }); - - expect(route).toBeNull(); - }); - - it("resolves the persisted workspace when another host is first online", () => { - const selection = resolveStartupWorkspaceSelection({ - ...baseInput, - anyOnlineHostServerId: "server-2", - workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }); - - expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); - }); - - it("redirects to the host root when no persisted workspace exists", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "server-2", - }); - - expect(route).toBe("/h/server-2"); - }); + hasGivenUpWaitingForHost: true, + }), + ).toEqual({ kind: "splash" }); }); - describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => { - it("redirects to the host that came online", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "srv_desktop", - }); - - expect(route).toBe("/h/srv_desktop"); - }); + it("restores the saved workspace only after the host registry proves the host exists", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-1" }], + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }), + ).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" }); }); - describe("scenario: both-succeed", () => { - it("leaves matching persisted workspace navigation to the workspace navigator", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "server-saved", + it("does not restore a workspace whose host is no longer saved", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" }, - }); - - expect(route).toBeNull(); - }); + hosts: [{ serverId: "server-next" }], + }), + ).toEqual({ kind: "redirect", href: "/h/server-next" }); }); - describe("scenario: both-fail (no host comes online, give-up timer fires)", () => { - it("redirects to the welcome route", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, + it("redirects to the online host when no saved workspace is selected", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + anyOnlineHostServerId: "srv-desktop", + }), + ).toEqual({ kind: "redirect", href: "/h/srv-desktop" }); + }); + + it("keeps a known connecting host in app-owned routing instead of showing welcome", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-saved" }], hasGivenUpWaitingForHost: true, - }); + }), + ).toEqual({ kind: "redirect", href: "/h/server-saved" }); + }); - expect(route).toBe(WELCOME_ROUTE); - }); - - it("still redirects to the host when one comes online before the timer expires", () => { - const route = resolveStartupRedirectRoute({ - ...baseInput, - anyOnlineHostServerId: "server-saved", + it("shows welcome only after the host registry is ready and no host exists", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, hasGivenUpWaitingForHost: true, - }); + }), + ).toEqual({ kind: "redirect", href: "/welcome" }); + }); - expect(route).toBe("/h/server-saved"); - }); + it("keeps host routes mounted while the host registry is loading", () => { + expect( + resolveStartupRoute({ + ...baseHostInput, + hostRegistryStatus: "loading", + }), + ).toEqual({ kind: "render" }); + }); + + it("keeps host routes mounted while the managed daemon is starting", () => { + expect( + resolveStartupRoute({ + ...baseHostInput, + startupBlocker: { kind: "managed-daemon-starting" }, + }), + ).toEqual({ kind: "render" }); + }); + + it("renders a host route once the route host is known", () => { + expect( + resolveStartupRoute({ + ...baseHostInput, + hosts: [{ serverId: "server-saved" }], + }), + ).toEqual({ kind: "render" }); + }); + + it("sends removed host routes to a saved host instead of welcome", () => { + expect( + resolveStartupRoute({ + ...baseHostInput, + route: { kind: "host", serverId: "server-removed" }, + hosts: [{ serverId: "server-next" }], + }), + ).toEqual({ kind: "redirect", href: "/h/server-next/open-project" }); + }); + + it("shows welcome from a host route only after the registry proves no hosts exist", () => { + expect( + resolveStartupRoute({ + ...baseHostInput, + route: { kind: "host", serverId: "server-removed" }, + }), + ).toEqual({ kind: "redirect", href: "/welcome" }); }); }); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index ca61b547b..8db0043fc 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -1,7 +1,11 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartResult } from "@/runtime/daemon-start-service"; import type { Href } from "expo-router"; -import { buildHostRootRoute } from "@/utils/host-routes"; +import { + buildHostOpenProjectRoute, + buildHostRootRoute, + buildHostWorkspaceRoute, +} from "@/utils/host-routes"; export interface HostRuntimeBootstrapStore { boot: () => void; @@ -56,7 +60,7 @@ export function startDaemonIfGateAllows(input: { }); } -export const WELCOME_ROUTE: Href = "/welcome"; +const WELCOME_ROUTE: Href = "/welcome"; export type StartupBlocker = | { kind: "none" } @@ -108,51 +112,123 @@ export function shouldRunStartupGiveUpTimer(input: { return input.startupBlocker.kind === "none"; } -export interface ResolveStartupRedirectInput { +export type StartupRegistryStatus = "loading" | "ready"; + +export interface IndexStartupRouteTarget { + kind: "index"; pathname: string; +} + +export interface HostStartupRouteTarget { + kind: "host"; + serverId: string | null; +} + +export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget; + +interface ResolveStartupRouteBaseInput { + startupBlocker: StartupBlocker; + hostRegistryStatus: StartupRegistryStatus; + hosts: readonly { serverId: string }[]; +} + +export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseInput { + route: IndexStartupRouteTarget; anyOnlineHostServerId: string | null; workspaceSelection: ActiveWorkspaceSelection | null; isWorkspaceSelectionLoaded: boolean; hasGivenUpWaitingForHost: boolean; } +export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput { + route: HostStartupRouteTarget; +} + +export type ResolveStartupRouteInput = ResolveIndexStartupRouteInput | ResolveHostStartupRouteInput; + +export type StartupRouteDecision = + | { kind: "render" } + | { kind: "splash" } + | { kind: "redirect"; href: Href }; + function isIndexPathname(pathname: string) { return pathname === "/" || pathname === ""; } -export function resolveStartupWorkspaceSelection( - input: ResolveStartupRedirectInput, -): ActiveWorkspaceSelection | null { - if (!isIndexPathname(input.pathname)) { - return null; +function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean { + if (!serverId) { + return false; } - if (!input.isWorkspaceSelectionLoaded) { - return null; - } - if (!input.workspaceSelection) { - return null; - } - return input.workspaceSelection; + return hosts.some((host) => host.serverId === serverId); } -export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null { - if (!isIndexPathname(input.pathname)) { - return null; +function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision { + if (!isIndexPathname(input.route.pathname)) { + return { kind: "render" }; } + if (!input.isWorkspaceSelectionLoaded) { - return null; + return { kind: "splash" }; + } + + const workspaceSelection = input.workspaceSelection; + if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) { + return { + kind: "redirect", + href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId), + }; } if (input.anyOnlineHostServerId) { - if (resolveStartupWorkspaceSelection(input)) { - return null; - } - return buildHostRootRoute(input.anyOnlineHostServerId); + return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) }; + } + + const savedHostServerId = input.hosts[0]?.serverId ?? null; + if (savedHostServerId) { + return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) }; } if (input.hasGivenUpWaitingForHost) { - return WELCOME_ROUTE; + return { kind: "redirect", href: WELCOME_ROUTE }; } - return null; + return { kind: "splash" }; +} + +function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision { + if (hostExists(input.hosts, input.route.serverId)) { + return { kind: "render" }; + } + + const fallbackServerId = input.hosts[0]?.serverId ?? null; + if (fallbackServerId) { + return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) }; + } + + return { kind: "redirect", href: WELCOME_ROUTE }; +} + +function isHostStartupRouteInput( + input: ResolveStartupRouteInput, +): input is ResolveHostStartupRouteInput { + return input.route.kind === "host"; +} + +export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRouteDecision { + if (isHostStartupRouteInput(input)) { + if (input.startupBlocker.kind !== "none" || input.hostRegistryStatus === "loading") { + return { kind: "render" }; + } + return resolveReadyHostStartupRoute(input); + } + + if (input.startupBlocker.kind !== "none") { + return { kind: "splash" }; + } + + if (input.hostRegistryStatus === "loading") { + return { kind: "splash" }; + } + + return resolveReadyIndexStartupRoute(input); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 4156f0a37..ea69ffee1 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -2,16 +2,13 @@ import React from "react"; import { Redirect, usePathname } from "expo-router"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; -import { - resolveStartupRedirectRoute, - resolveStartupWorkspaceSelection, -} from "@/app/host-runtime-bootstrap"; +import { resolveStartupRoute } from "@/app/host-runtime-bootstrap"; +import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime"; import { useIsLastWorkspaceSelectionHydrated, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const isDesktop = shouldUseDesktopDaemon(); @@ -19,37 +16,24 @@ export default function Index() { const pathname = usePathname(); const bootstrapState = useHostRuntimeBootstrapState(); const anyOnlineHostServerId = useEarliestOnlineHostServerId(); + const hosts = useHosts(); + const hostRegistryStatus = useHostRegistryStatus(); const workspaceSelection = useLastWorkspaceSelection(); const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); - const redirectRoute = resolveStartupRedirectRoute({ - pathname, - anyOnlineHostServerId, - workspaceSelection, - isWorkspaceSelectionLoaded, - hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, - }); - const startupWorkspaceSelection = resolveStartupWorkspaceSelection({ - pathname, + const startupRoute = resolveStartupRoute({ + route: { kind: "index", pathname }, + startupBlocker: bootstrapState.startupBlocker, + hostRegistryStatus, + hosts, anyOnlineHostServerId, workspaceSelection, isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); - if (startupWorkspaceSelection) { - return ( - - ); - } - - if (redirectRoute) { - return ; + if (startupRoute.kind === "redirect") { + return ; } return ; diff --git a/packages/app/src/components/host-route-bootstrap-boundary.tsx b/packages/app/src/components/host-route-bootstrap-boundary.tsx index 6816284fc..27e98d896 100644 --- a/packages/app/src/components/host-route-bootstrap-boundary.tsx +++ b/packages/app/src/components/host-route-bootstrap-boundary.tsx @@ -1,11 +1,13 @@ import type { ReactNode } from "react"; import { useHostRuntimeBootstrapState } from "@/app/_layout"; +import { useHostRegistryStatus } from "@/runtime/host-runtime"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) { const bootstrapState = useHostRuntimeBootstrapState(); + const hostRegistryStatus = useHostRegistryStatus(); - if (bootstrapState.startupBlocker.kind !== "none") { + if (bootstrapState.startupBlocker.kind !== "none" || hostRegistryStatus === "loading") { return ; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 41d681814..3bfc32980 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -40,6 +40,7 @@ import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; +export type HostRegistryStatus = "loading" | "ready"; export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } @@ -1238,6 +1239,7 @@ export class HostRuntimeStore { private version = 0; private hostListVersion = 0; private hosts: HostProfile[] = []; + private hostRegistryStatus: HostRegistryStatus = "loading"; private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); @@ -1254,6 +1256,10 @@ export class HostRuntimeStore { return this.hosts; } + getHostRegistryStatus(): HostRegistryStatus { + return this.hostRegistryStatus; + } + subscribeHostList(listener: () => void): () => void { this.hostListListeners.add(listener); return () => { @@ -1299,6 +1305,7 @@ export class HostRuntimeStore { } private async loadFromStorage(): Promise { + let shouldPersistHosts = false; try { const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); if (!stored) { @@ -1314,12 +1321,17 @@ export class HostRuntimeStore { const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); this.hosts = profiles; this.syncHosts(profiles); - this.emitHostList(); if (profiles.length !== normalizedProfiles.length) { - void this.persistHosts(); + shouldPersistHosts = true; } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); + } finally { + this.hostRegistryStatus = "ready"; + this.emitHostList(); + if (shouldPersistHosts) { + void this.persistHosts(); + } } } @@ -2069,6 +2081,15 @@ export function useHosts(): HostProfile[] { ); } +export function useHostRegistryStatus(): HostRegistryStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribeHostList(onStoreChange), + () => store.getHostRegistryStatus(), + () => store.getHostRegistryStatus(), + ); +} + export interface HostMutations { upsertDirectConnection: (input: { serverId: string; diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 1a2e7aca3..00fb6f7e5 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -16,7 +16,6 @@ import { parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, parseWorkspaceOpenIntent, - resolveKnownHostRoute, } from "./host-routes"; describe("parseHostAgentRouteFromPathname", () => { @@ -191,32 +190,3 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("daemon")).toBe("host"); }); }); - -describe("resolveKnownHostRoute", () => { - it("renders when the route host is still saved", () => { - expect( - resolveKnownHostRoute({ - routeServerId: "srv-current", - hosts: [{ serverId: "srv-current" }, { serverId: "srv-next" }], - }), - ).toEqual({ kind: "render" }); - }); - - it("sends removed host routes to the next saved host home", () => { - expect( - resolveKnownHostRoute({ - routeServerId: "srv-removed", - hosts: [{ serverId: "srv-next" }], - }), - ).toEqual({ kind: "redirect", href: "/h/srv-next/open-project" }); - }); - - it("sends host routes to welcome when no hosts are saved", () => { - expect( - resolveKnownHostRoute({ - routeServerId: "srv-removed", - hosts: [], - }), - ).toEqual({ kind: "redirect", href: "/welcome" }); - }); -}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index ec9825a3c..256c98def 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -355,27 +355,6 @@ export function buildHostOpenProjectRoute(serverId: string) { return `${base}/open-project` as const; } -export type KnownHostRouteResolution = - | { kind: "render" } - | { kind: "redirect"; href: ReturnType | "/welcome" }; - -export function resolveKnownHostRoute(input: { - routeServerId: string | null | undefined; - hosts: readonly { serverId: string }[]; -}): KnownHostRouteResolution { - const routeServerId = trimNonEmpty(input.routeServerId); - if (routeServerId && input.hosts.some((host) => host.serverId === routeServerId)) { - return { kind: "render" }; - } - - const fallbackServerId = input.hosts[0]?.serverId; - if (fallbackServerId) { - return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) }; - } - - return { kind: "redirect", href: "/welcome" }; -} - export function buildHostNewWorkspaceRoute( serverId: string, sourceDirectory?: string,