mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(app): preserve workspace route during desktop refresh
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user