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:
Mohamed Boudra
2026-06-23 21:09:35 +07:00
parent 1970a14349
commit 78d46a8a82
8 changed files with 235 additions and 32 deletions
+2 -16
View File
@@ -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>
+27 -6
View File
@@ -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>;
}
+36 -6
View File
@@ -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,
}),
})}
/>
);
}
+16 -1
View File
@@ -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);
}
@@ -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");
});
});
@@ -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,
),
};
}