Restore workspace after reopening app
Native state restore can resume at the host route instead of the root index, so host home now resolves the remembered workspace itself. Keep shared route policy outside src/app so Expo Router does not register it as a page.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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() {
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
|
||||
@@ -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 <KnownHostRoute />;
|
||||
}
|
||||
@@ -24,8 +32,21 @@ function KnownHostRoute() {
|
||||
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 />;
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
return stack;
|
||||
}
|
||||
|
||||
return <HostRouteProvider serverId={routeServerId}>{stack}</HostRouteProvider>;
|
||||
}
|
||||
|
||||
@@ -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 <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||
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 <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
href={resolveHostIndexRoute({
|
||||
serverId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
const HostRouteServerIdContext = createContext<string | null>(null);
|
||||
|
||||
export function HostRouteProvider({
|
||||
children,
|
||||
serverId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
serverId: string;
|
||||
}) {
|
||||
return (
|
||||
<HostRouteServerIdContext.Provider value={serverId}>
|
||||
{children}
|
||||
</HostRouteServerIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostRouteServerId(): string | null {
|
||||
return useContext(HostRouteServerIdContext);
|
||||
}
|
||||
+56
@@ -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");
|
||||
});
|
||||
});
|
||||
+45
-3
@@ -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<WorkspaceSelectionStatus, "missing">;
|
||||
} {
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user