fix(app): preserve workspace route during desktop refresh

This commit is contained in:
Mohamed Boudra
2026-06-11 16:08:50 +07:00
parent f5a7055e7e
commit d802534e7b
12 changed files with 395 additions and 235 deletions

View File

@@ -533,13 +533,23 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
);
}
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
function startMetro(input: {
metroPort: number;
daemonPort: number;
buffer: ReturnType<typeof createLineBuffer>;
}): 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<typeof createLineBuffe
.split("\n")
.filter((line) => 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<typeof createLineBuffe
.split("\n")
.filter((line) => 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,

View File

@@ -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<string, unknown>) => Promise<unknown>;
@@ -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;

View File

@@ -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<void> {
await closeButton.first().click();
}
async function openWorkspaceThroughApp(
page: Page,
input: {
serverId: string;
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
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<ReturnType<typeof seedWorkspace>>;
},
): Promise<void> {
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<WebSocketRoute>();
@@ -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,
}) => {

View File

@@ -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 (
<HostRouteBootstrapBoundary>
<KnownHostRoute />
</HostRouteBootstrapBoundary>
);
return <KnownHostRoute />;
}
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 <Redirect href={resolution.href} />;
if (startupRoute.kind === "redirect") {
return <Redirect href={startupRoute.href} />;
}
// 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 <Slot />;
}

View File

@@ -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 (
<NewWorkspaceScreen
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
/>
<HostRouteBootstrapBoundary>
<NewWorkspaceScreen
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
/>
</HostRouteBootstrapBoundary>
);
}

View File

@@ -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" });
});
});

View File

@@ -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);
}

View File

@@ -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 (
<Redirect
href={buildHostWorkspaceRoute(
startupWorkspaceSelection.serverId,
startupWorkspaceSelection.workspaceId,
)}
/>
);
}
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
if (startupRoute.kind === "redirect") {
return <Redirect href={startupRoute.href} />;
}
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;

View File

@@ -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 <StartupSplashScreen bootstrapState={bootstrapState} />;
}

View File

@@ -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<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
@@ -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<void> {
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;

View File

@@ -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" });
});
});

View File

@@ -355,27 +355,6 @@ export function buildHostOpenProjectRoute(serverId: string) {
return `${base}/open-project` as const;
}
export type KnownHostRouteResolution =
| { kind: "render" }
| { kind: "redirect"; href: ReturnType<typeof buildHostOpenProjectRoute> | "/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,