diff --git a/docs/development.md b/docs/development.md
index 03d638f78..fba9b95a3 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -49,6 +49,38 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
+### Expo Router layout ownership
+
+Each layout owns only the routes directly inside its directory. In the root
+layout, register `h/[serverId]`; do not register host leaf routes such as
+`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
+`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
+routes with its own nested stack and relative screen names:
+`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
+warns with `[Layout children]: No route named ...` when a layout registers
+grandchildren. Treat that warning as a route-tree bug: on native, this shape can
+leave a nested index route mounted without its local dynamic params and render a
+blank screen.
+
+Do not paper over missing required route params by reading global params in the
+leaf. Required dynamic params belong to the matched route. If
+`useLocalSearchParams()` misses one, fix the layout ownership.
+
+Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
+`.tsx` files there as routes, which produces `missing the required default
+export` warnings and pollutes the route tree. Put shared route policy in
+`src/navigation`, `src/utils`, or another non-route directory.
+
+Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
+workspace for that host after the workspace-selection store hydrates unless the
+host's hydrated workspace list proves that workspace is gone; hosts without a
+remembered workspace go to `open-project`.
+
+Keep workspace identity and retention outside native-stack `getId`/
+`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
+Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
+reordering an already-mounted workspace screen.
+
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 5b7e3165a..94061e25a 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -56,7 +56,7 @@ import {
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
-} from "@/app/host-runtime-bootstrap";
+} from "@/navigation/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -877,8 +877,6 @@ function FaviconStatusSync() {
return null;
}
-const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
-
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
@@ -903,19 +901,7 @@ function RootStack() {
- {/*
- Do not add getId or dangerouslySingular back to the workspace route.
- Expo Router maps dangerouslySingular to React Navigation getId, and
- getId repeatedly breaks Android native-stack/Fabric by reordering an
- already-mounted workspace screen. Keep workspace identity/retention
- outside this route-level native-stack API.
- */}
-
-
-
-
-
-
+
diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx
index c84e1d397..0fb551f30 100644
--- a/packages/app/src/app/h/[serverId]/_layout.tsx
+++ b/packages/app/src/app/h/[serverId]/_layout.tsx
@@ -1,8 +1,16 @@
-import { Redirect, Slot, useLocalSearchParams } from "expo-router";
+import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
-import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
+import { HostRouteProvider } from "@/navigation/host-route-context";
+import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
+const HOST_STACK_SCREEN_OPTIONS = {
+ headerShown: false,
+ animation: "none" as const,
+};
+
+const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
+
export default function HostRouteLayout() {
return ;
}
@@ -24,8 +32,21 @@ function KnownHostRoute() {
return ;
}
- // Keep the host Slot mounted while startup gates are active. React Navigation
- // web can reserialize a shallower tree and drop nested workspace URL segments
- // if the layout swaps Slot for a splash; leaf routes own the splash boundary.
- return ;
+ const stack = (
+
+
+
+
+
+
+
+
+
+ );
+
+ if (!routeServerId) {
+ return stack;
+ }
+
+ return {stack};
}
diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx
index fa80724e5..46ba5d8fb 100644
--- a/packages/app/src/app/h/[serverId]/index.tsx
+++ b/packages/app/src/app/h/[serverId]/index.tsx
@@ -1,9 +1,39 @@
-import { Redirect, useLocalSearchParams } from "expo-router";
-import { buildHostOpenProjectRoute } from "@/utils/host-routes";
+import { Redirect } from "expo-router";
+import { useHostRouteServerId } from "@/navigation/host-route-context";
+import {
+ resolveHostIndexRoute,
+ resolveWorkspaceSelectionStatus,
+} from "@/navigation/host-runtime-bootstrap";
+import { StartupSplashScreen } from "@/screens/startup-splash-screen";
+import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
+import {
+ useIsLastWorkspaceSelectionHydrated,
+ useLastWorkspaceSelection,
+} from "@/stores/navigation-active-workspace-store";
export default function HostIndexRoute() {
- const params = useLocalSearchParams<{ serverId?: string }>();
- const serverId = typeof params.serverId === "string" ? params.serverId : "";
- if (!serverId) return null;
- return ;
+ const serverId = useHostRouteServerId();
+ const workspaceSelection = useLastWorkspaceSelection();
+ const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
+ const workspaceSelectionWorkspaceId =
+ workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
+ const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
+ const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
+
+ if (!serverId || !isWorkspaceSelectionLoaded) {
+ return ;
+ }
+
+ return (
+
+ );
}
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index ea69ffee1..e0649f65a 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -2,8 +2,12 @@ import React from "react";
import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
-import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
+import {
+ resolveStartupRoute,
+ resolveWorkspaceSelectionStatus,
+} from "@/navigation/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
+import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
@@ -20,6 +24,13 @@ export default function Index() {
const hostRegistryStatus = useHostRegistryStatus();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
+ const workspaceSelectionServerId = workspaceSelection?.serverId ?? null;
+ const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
+ const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId);
+ const workspaceSelectionExists = useWorkspaceExists(
+ workspaceSelectionServerId,
+ workspaceSelectionWorkspaceId,
+ );
const startupRoute = resolveStartupRoute({
route: { kind: "index", pathname },
@@ -28,6 +39,10 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
+ workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
+ hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
+ workspaceExists: workspaceSelectionExists,
+ }),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
diff --git a/packages/app/src/navigation/host-route-context.tsx b/packages/app/src/navigation/host-route-context.tsx
new file mode 100644
index 000000000..27106554f
--- /dev/null
+++ b/packages/app/src/navigation/host-route-context.tsx
@@ -0,0 +1,21 @@
+import { createContext, type ReactNode, useContext } from "react";
+
+const HostRouteServerIdContext = createContext(null);
+
+export function HostRouteProvider({
+ children,
+ serverId,
+}: {
+ children: ReactNode;
+ serverId: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useHostRouteServerId(): string | null {
+ return useContext(HostRouteServerIdContext);
+}
diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts
similarity index 83%
rename from packages/app/src/app/host-runtime-bootstrap.test.ts
rename to packages/app/src/navigation/host-runtime-bootstrap.test.ts
index 64d0f013d..5d16ed09a 100644
--- a/packages/app/src/app/host-runtime-bootstrap.test.ts
+++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
+ resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -200,6 +201,7 @@ describe("resolveStartupRoute", () => {
hosts: [],
anyOnlineHostServerId: null,
workspaceSelection: null,
+ workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
@@ -255,10 +257,22 @@ describe("resolveStartupRoute", () => {
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "exists",
}),
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
});
+ it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
+ expect(
+ resolveStartupRoute({
+ ...baseIndexInput,
+ hosts: [{ serverId: "server-1" }],
+ workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "missing",
+ }),
+ ).toEqual({ kind: "redirect", href: "/h/server-1" });
+ });
+
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
expect(
resolveStartupRoute({
@@ -344,3 +358,45 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
+
+describe("resolveHostIndexRoute", () => {
+ it("restores the remembered workspace when the host index is reopened for the same host", () => {
+ expect(
+ resolveHostIndexRoute({
+ serverId: "server-saved",
+ workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "exists",
+ }),
+ ).toEqual("/h/server-saved/workspace/workspace-a");
+ });
+
+ it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
+ expect(
+ resolveHostIndexRoute({
+ serverId: "server-saved",
+ workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "unknown",
+ }),
+ ).toEqual("/h/server-saved/workspace/workspace-a");
+ });
+
+ it("opens project selection when the remembered workspace is proven missing", () => {
+ expect(
+ resolveHostIndexRoute({
+ serverId: "server-saved",
+ workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "missing",
+ }),
+ ).toEqual("/h/server-saved/open-project");
+ });
+
+ it("opens project selection when the remembered workspace belongs to another host", () => {
+ expect(
+ resolveHostIndexRoute({
+ serverId: "server-saved",
+ workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
+ workspaceSelectionStatus: "exists",
+ }),
+ ).toEqual("/h/server-saved/open-project");
+ });
+});
diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts
similarity index 81%
rename from packages/app/src/app/host-runtime-bootstrap.ts
rename to packages/app/src/navigation/host-runtime-bootstrap.ts
index 8db0043fc..77a8cfda6 100644
--- a/packages/app/src/app/host-runtime-bootstrap.ts
+++ b/packages/app/src/navigation/host-runtime-bootstrap.ts
@@ -136,6 +136,7 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
route: IndexStartupRouteTarget;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
+ workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
@@ -151,6 +152,42 @@ export type StartupRouteDecision =
| { kind: "splash" }
| { kind: "redirect"; href: Href };
+export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
+
+function shouldRestoreWorkspaceSelection(input: {
+ workspaceSelection: ActiveWorkspaceSelection | null;
+ workspaceSelectionStatus: WorkspaceSelectionStatus;
+}): input is {
+ workspaceSelection: ActiveWorkspaceSelection;
+ workspaceSelectionStatus: Exclude;
+} {
+ return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
+}
+
+export function resolveWorkspaceSelectionStatus(input: {
+ hasHydratedWorkspaces: boolean;
+ workspaceExists: boolean;
+}): WorkspaceSelectionStatus {
+ if (input.workspaceExists) {
+ return "exists";
+ }
+ return input.hasHydratedWorkspaces ? "missing" : "unknown";
+}
+
+export function resolveHostIndexRoute(input: {
+ serverId: string;
+ workspaceSelection: ActiveWorkspaceSelection | null;
+ workspaceSelectionStatus: WorkspaceSelectionStatus;
+}): Href {
+ if (
+ input.workspaceSelection?.serverId === input.serverId &&
+ shouldRestoreWorkspaceSelection(input)
+ ) {
+ return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
+ }
+ return buildHostOpenProjectRoute(input.serverId);
+}
+
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
@@ -171,11 +208,16 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
return { kind: "splash" };
}
- const workspaceSelection = input.workspaceSelection;
- if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
+ if (
+ shouldRestoreWorkspaceSelection(input) &&
+ hostExists(input.hosts, input.workspaceSelection.serverId)
+ ) {
return {
kind: "redirect",
- href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
+ href: buildHostWorkspaceRoute(
+ input.workspaceSelection.serverId,
+ input.workspaceSelection.workspaceId,
+ ),
};
}