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
+19 -5
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 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, cwd: appDir,
env: { env: {
...process.env, ...process.env,
BROWSER: "none", 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"], stdio: ["ignore", "pipe", "pipe"],
detached: false, detached: false,
@@ -551,7 +561,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
.split("\n") .split("\n")
.filter((line) => line.trim()); .filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
buffer.add(`[stdout] ${line}`); input.buffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`); console.log(`[metro] ${line}`);
} }
}); });
@@ -562,7 +572,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
.split("\n") .split("\n")
.filter((line) => line.trim()); .filter((line) => line.trim());
for (const line of lines) { for (const line of lines) {
buffer.add(`[stderr] ${line}`); input.buffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`); console.error(`[metro] ${line}`);
} }
}); });
@@ -685,7 +695,11 @@ export default async function globalSetup() {
try { try {
const relayPort = await startRelay(new Set([port, metroPort])); const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer); metroProcess = startMetro({
metroPort,
daemonPort: port,
buffer: metroLineBuffer,
});
daemonProcess = startDaemon({ daemonProcess = startDaemon({
port, port,
relayPort, relayPort,
+18 -7
View File
@@ -59,6 +59,10 @@ export interface DesktopBridgeConfig {
daemonLogPath?: string; daemonLogPath?: string;
/** Initial manageBuiltInDaemon setting. Defaults to false. */ /** Initial manageBuiltInDaemon setting. Defaults to false. */
manageBuiltInDaemon?: boolean; 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 * Controls what dialog.ask returns when the daemon management confirm dialog
* fires. True = confirm (proceed with the action), false = cancel. Defaults to * fires. True = confirm (proceed with the action), false = cancel. Defaults to
@@ -124,7 +128,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return { return {
serverId: cfg.serverId, serverId: cfg.serverId,
status: daemonRunning ? "running" : "stopped", status: daemonRunning ? "running" : "stopped",
listen: "127.0.0.1:6767", listen: cfg.daemonListen ?? "127.0.0.1:6767",
hostname: null, hostname: null,
pid: currentPid, pid: currentPid,
home: "", 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: { const desktopBridge: {
platform: string; platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>; 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") { if (command === "start_desktop_daemon") {
startCount += 1; return startDesktopDaemon();
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 null; return null;
@@ -9,7 +9,7 @@ import {
openWorkspaceWithAgents, openWorkspaceWithAgents,
} from "./helpers/archive-tab"; } from "./helpers/archive-tab";
import { expectComposerVisible } from "./helpers/composer"; 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 { seedWorkspace } from "./helpers/seed-client";
import { import {
getVisibleWorkspaceAgentTabIds, getVisibleWorkspaceAgentTabIds,
@@ -32,6 +32,7 @@ import {
} from "./helpers/workspace-ui"; } from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings"; import { clickSettingsBackToWorkspace } from "./helpers/settings";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge } from "./helpers/desktop-updates";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -79,6 +80,43 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click(); 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) { async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true; let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>(); 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 ({ test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page, page,
}) => { }) => {
+17 -11
View File
@@ -1,25 +1,31 @@
import { Redirect, Slot, useLocalSearchParams } from "expo-router"; import { Redirect, Slot, useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { useHosts } from "@/runtime/host-runtime"; import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import { resolveKnownHostRoute } from "@/utils/host-routes"; import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
export default function HostRouteLayout() { export default function HostRouteLayout() {
return ( return <KnownHostRoute />;
<HostRouteBootstrapBoundary>
<KnownHostRoute />
</HostRouteBootstrapBoundary>
);
} }
function KnownHostRoute() { function KnownHostRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>(); const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const hosts = useHosts(); const hosts = useHosts();
const hostRegistryStatus = useHostRegistryStatus();
const bootstrapState = useHostRuntimeBootstrapState();
const routeServerId = typeof params.serverId === "string" ? params.serverId : null; 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") { if (startupRoute.kind === "redirect") {
return <Redirect href={resolution.href} />; 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 />; return <Slot />;
} }
+9 -6
View File
@@ -1,4 +1,5 @@
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() { export default function HostNewWorkspaceRoute() {
@@ -14,11 +15,13 @@ export default function HostNewWorkspaceRoute() {
const projectId = typeof params.projectId === "string" ? params.projectId : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
return ( return (
<NewWorkspaceScreen <HostRouteBootstrapBoundary>
serverId={serverId} <NewWorkspaceScreen
sourceDirectory={sourceDirectory} serverId={serverId}
displayName={displayName} sourceDirectory={sourceDirectory}
projectId={projectId} displayName={displayName}
/> projectId={projectId}
/>
</HostRouteBootstrapBoundary>
); );
} }
@@ -2,11 +2,9 @@ import { describe, expect, it, vi } from "vitest";
import { import {
resolveStartupBlocker, resolveStartupBlocker,
resolveStartupNavigationReady, resolveStartupNavigationReady,
resolveStartupRedirectRoute, resolveStartupRoute,
resolveStartupWorkspaceSelection,
shouldRunStartupGiveUpTimer, shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap, startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap"; } from "./host-runtime-bootstrap";
function createFakeStore() { function createFakeStore() {
@@ -194,131 +192,154 @@ describe("startup blocking policy", () => {
}); });
}); });
describe("resolveStartupRedirectRoute", () => { describe("resolveStartupRoute", () => {
const baseInput = { const baseIndexInput = {
pathname: "/", route: { kind: "index" as const, pathname: "/" },
startupBlocker: { kind: "none" as const },
hostRegistryStatus: "ready" as const,
hosts: [],
anyOnlineHostServerId: null, anyOnlineHostServerId: null,
workspaceSelection: null, workspaceSelection: null,
isWorkspaceSelectionLoaded: true, isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false, 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( expect(
resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...baseIndexInput,
pathname: "/h/server-1", route: { kind: "index", pathname: "/settings" },
anyOnlineHostServerId: "server-1",
}), }),
).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( expect(
resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...baseIndexInput,
anyOnlineHostServerId: "server-1", anyOnlineHostServerId: "server-1",
isWorkspaceSelectionLoaded: false, isWorkspaceSelectionLoaded: false,
}), }),
).toBeNull(); ).toEqual({ kind: "splash" });
}); });
it("waits while no host is online and the give-up timer has not fired", () => { it("keeps startup on the splash while the host registry is loading", () => {
expect(resolveStartupRedirectRoute(baseInput)).toBeNull(); expect(
resolveStartupRoute({
...baseIndexInput,
hostRegistryStatus: "loading",
}),
).toEqual({ kind: "splash" });
}); });
describe("scenario: saved-host-online", () => { it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => { expect(
const route = resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...baseIndexInput,
anyOnlineHostServerId: "server-1", hostRegistryStatus: "loading",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
}); hasGivenUpWaitingForHost: true,
}),
expect(route).toBeNull(); ).toEqual({ kind: "splash" });
});
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");
});
}); });
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => { it("restores the saved workspace only after the host registry proves the host exists", () => {
it("redirects to the host that came online", () => { expect(
const route = resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...baseIndexInput,
anyOnlineHostServerId: "srv_desktop", hosts: [{ serverId: "server-1" }],
}); workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
}),
expect(route).toBe("/h/srv_desktop"); ).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
});
}); });
describe("scenario: both-succeed", () => { it("does not restore a workspace whose host is no longer saved", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => { expect(
const route = resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...baseIndexInput,
anyOnlineHostServerId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" }, workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
}); hosts: [{ serverId: "server-next" }],
}),
expect(route).toBeNull(); ).toEqual({ kind: "redirect", href: "/h/server-next" });
});
}); });
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => { it("redirects to the online host when no saved workspace is selected", () => {
it("redirects to the welcome route", () => { expect(
const route = resolveStartupRedirectRoute({ resolveStartupRoute({
...baseInput, ...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, hasGivenUpWaitingForHost: true,
}); }),
).toEqual({ kind: "redirect", href: "/h/server-saved" });
});
expect(route).toBe(WELCOME_ROUTE); it("shows welcome only after the host registry is ready and no host exists", () => {
}); expect(
resolveStartupRoute({
it("still redirects to the host when one comes online before the timer expires", () => { ...baseIndexInput,
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
hasGivenUpWaitingForHost: true, 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" });
}); });
}); });
+101 -25
View File
@@ -1,7 +1,11 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service"; import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router"; import type { Href } from "expo-router";
import { buildHostRootRoute } from "@/utils/host-routes"; import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore { export interface HostRuntimeBootstrapStore {
boot: () => void; boot: () => void;
@@ -56,7 +60,7 @@ export function startDaemonIfGateAllows(input: {
}); });
} }
export const WELCOME_ROUTE: Href = "/welcome"; const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker = export type StartupBlocker =
| { kind: "none" } | { kind: "none" }
@@ -108,51 +112,123 @@ export function shouldRunStartupGiveUpTimer(input: {
return input.startupBlocker.kind === "none"; return input.startupBlocker.kind === "none";
} }
export interface ResolveStartupRedirectInput { export type StartupRegistryStatus = "loading" | "ready";
export interface IndexStartupRouteTarget {
kind: "index";
pathname: string; 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; anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null; workspaceSelection: ActiveWorkspaceSelection | null;
isWorkspaceSelectionLoaded: boolean; isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: 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) { function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === ""; return pathname === "/" || pathname === "";
} }
export function resolveStartupWorkspaceSelection( function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean {
input: ResolveStartupRedirectInput, if (!serverId) {
): ActiveWorkspaceSelection | null { return false;
if (!isIndexPathname(input.pathname)) {
return null;
} }
if (!input.isWorkspaceSelectionLoaded) { return hosts.some((host) => host.serverId === serverId);
return null;
}
if (!input.workspaceSelection) {
return null;
}
return input.workspaceSelection;
} }
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null { function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision {
if (!isIndexPathname(input.pathname)) { if (!isIndexPathname(input.route.pathname)) {
return null; return { kind: "render" };
} }
if (!input.isWorkspaceSelectionLoaded) { 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 (input.anyOnlineHostServerId) {
if (resolveStartupWorkspaceSelection(input)) { return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
return null; }
}
return buildHostRootRoute(input.anyOnlineHostServerId); const savedHostServerId = input.hosts[0]?.serverId ?? null;
if (savedHostServerId) {
return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) };
} }
if (input.hasGivenUpWaitingForHost) { 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);
} }
+11 -27
View File
@@ -2,16 +2,13 @@ import React from "react";
import { Redirect, usePathname } from "expo-router"; import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
resolveStartupRedirectRoute, import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
resolveStartupWorkspaceSelection,
} from "@/app/host-runtime-bootstrap";
import { import {
useIsLastWorkspaceSelectionHydrated, useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection, useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const isDesktop = shouldUseDesktopDaemon(); const isDesktop = shouldUseDesktopDaemon();
@@ -19,37 +16,24 @@ export default function Index() {
const pathname = usePathname(); const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState(); const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const hosts = useHosts();
const hostRegistryStatus = useHostRegistryStatus();
const workspaceSelection = useLastWorkspaceSelection(); const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated(); const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const redirectRoute = resolveStartupRedirectRoute({ const startupRoute = resolveStartupRoute({
pathname, route: { kind: "index", pathname },
anyOnlineHostServerId, startupBlocker: bootstrapState.startupBlocker,
workspaceSelection, hostRegistryStatus,
isWorkspaceSelectionLoaded, hosts,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
pathname,
anyOnlineHostServerId, anyOnlineHostServerId,
workspaceSelection, workspaceSelection,
isWorkspaceSelectionLoaded, isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
}); });
if (startupWorkspaceSelection) { if (startupRoute.kind === "redirect") {
return ( return <Redirect href={startupRoute.href} />;
<Redirect
href={buildHostWorkspaceRoute(
startupWorkspaceSelection.serverId,
startupWorkspaceSelection.workspaceId,
)}
/>
);
}
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
} }
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />; return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
@@ -1,11 +1,13 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState } from "@/app/_layout"; import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { useHostRegistryStatus } from "@/runtime/host-runtime";
import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) { export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const bootstrapState = useHostRuntimeBootstrapState(); const bootstrapState = useHostRuntimeBootstrapState();
const hostRegistryStatus = useHostRegistryStatus();
if (bootstrapState.startupBlocker.kind !== "none") { if (bootstrapState.startupBlocker.kind !== "none" || hostRegistryStatus === "loading") {
return <StartupSplashScreen bootstrapState={bootstrapState} />; return <StartupSplashScreen bootstrapState={bootstrapState} />;
} }
+23 -2
View File
@@ -40,6 +40,7 @@ import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
export type ActiveConnection = export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string } | { type: "directTcp"; endpoint: string; display: string }
@@ -1238,6 +1239,7 @@ export class HostRuntimeStore {
private version = 0; private version = 0;
private hostListVersion = 0; private hostListVersion = 0;
private hosts: HostProfile[] = []; private hosts: HostProfile[] = [];
private hostRegistryStatus: HostRegistryStatus = "loading";
private deps: HostRuntimeControllerDeps; private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>(); private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>(); private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
@@ -1254,6 +1256,10 @@ export class HostRuntimeStore {
return this.hosts; return this.hosts;
} }
getHostRegistryStatus(): HostRegistryStatus {
return this.hostRegistryStatus;
}
subscribeHostList(listener: () => void): () => void { subscribeHostList(listener: () => void): () => void {
this.hostListListeners.add(listener); this.hostListListeners.add(listener);
return () => { return () => {
@@ -1299,6 +1305,7 @@ export class HostRuntimeStore {
} }
private async loadFromStorage(): Promise<void> { private async loadFromStorage(): Promise<void> {
let shouldPersistHosts = false;
try { try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
if (!stored) { if (!stored) {
@@ -1314,12 +1321,17 @@ export class HostRuntimeStore {
const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
this.hosts = profiles; this.hosts = profiles;
this.syncHosts(profiles); this.syncHosts(profiles);
this.emitHostList();
if (profiles.length !== normalizedProfiles.length) { if (profiles.length !== normalizedProfiles.length) {
void this.persistHosts(); shouldPersistHosts = true;
} }
} catch (error) { } catch (error) {
console.error("[HostRuntime] Failed to load host registry from storage", 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 { export interface HostMutations {
upsertDirectConnection: (input: { upsertDirectConnection: (input: {
serverId: string; serverId: string;
@@ -16,7 +16,6 @@ import {
parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
resolveKnownHostRoute,
} from "./host-routes"; } from "./host-routes";
describe("parseHostAgentRouteFromPathname", () => { describe("parseHostAgentRouteFromPathname", () => {
@@ -191,32 +190,3 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("daemon")).toBe("host"); 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" });
});
});
-21
View File
@@ -355,27 +355,6 @@ export function buildHostOpenProjectRoute(serverId: string) {
return `${base}/open-project` as const; 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( export function buildHostNewWorkspaceRoute(
serverId: string, serverId: string,
sourceDirectory?: string, sourceDirectory?: string,