diff --git a/CLAUDE.md b/CLAUDE.md index 1d9057a37..0dac6f101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | | [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | +| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | @@ -72,6 +73,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. +- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).** - **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules: - Run only the specific test file you changed: `npx vitest run --bail=1` - Never run `npm run test` for an entire workspace unless explicitly asked. diff --git a/docs/development.md b/docs/development.md index 8a20c1a99..55624c9e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,46 +49,11 @@ 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 +### Expo Router -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`. - -When app-wide routes such as `/new` navigate back into a host workspace, use -`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the -leaf workspace URL. The root stack owns `h/[serverId]`; the host stack owns -`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the -root host route and pass the nested workspace screen, or Expo Router can append -extra hidden workspace deck entries. Those hidden entries are not harmless: -composer floating panels can measure against the wrong deck and disappear -offscreen. - -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. +Route ownership, startup restore, and native blank-screen gotchas live in +[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`, +startup routing, remembered workspace restore, or active workspace selection. ### iOS simulator preview service diff --git a/docs/expo-router.md b/docs/expo-router.md new file mode 100644 index 000000000..2150fe8c9 --- /dev/null +++ b/docs/expo-router.md @@ -0,0 +1,115 @@ +# Expo Router + +Paseo's mobile route tree is fragile because Expo Router and React Navigation do +not fail loudly when a nested native route is mounted under the wrong layout. The +usual symptom is a white or blank native screen with no JavaScript crash. + +Read this before changing `packages/app/src/app`, startup routing, remembered +workspace restore, or active workspace selection. + +## Ownership + +Each layout owns only the routes directly inside its directory. + +- The root layout registers `h/[serverId]`. +- The root layout does not register host leaf routes such as + `h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or + `h/[serverId]/index`. +- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with + relative screen names: `index`, `workspace/[workspaceId]/index`, + `agent/[agentId]`, `sessions`, `open-project`, and `settings`. + +Expo Router warns with `[Layout children]: No route named ...` when a layout +registers grandchildren. Treat that warning as a route-tree bug. On native, that +shape can leave a nested index route mounted without its local dynamic params and +render a blank screen. + +## Startup + +The root `/` route chooses a host boundary. It does not jump directly into a host +leaf. + +- Good: `/` -> `/h/[serverId]` +- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]` + +`/h/[serverId]` is the host home route. The host index restores the last +remembered workspace for that host after the remembered selection has hydrated +and the workspace has not been proven missing. If there is no restorable +workspace, it goes to global `/open-project`. + +This split is deliberate. The host layout must mount first so native local +dynamic params exist before any nested workspace leaf is selected. + +## App-Wide Route Hops + +When app-wide routes such as `/new` navigate back into a host workspace, use +`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the +leaf workspace URL. + +The root stack owns `h/[serverId]`; the host stack owns +`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the +root host route and pass the nested workspace screen, or Expo Router can append +extra hidden workspace deck entries. + +Those hidden entries are not harmless: composer floating panels can measure +against the wrong deck and disappear offscreen. + +## Params + +Required dynamic params belong to the matched route. + +Do not paper over missing required params by reading global params in the leaf. +If `useLocalSearchParams()` misses a required param, fix layout ownership or the +startup route shape. + +Use the host route context for host-owned leaves that need the host id after +`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an +unmatched tree by guessing from global state. + +## App Directory + +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`, stores, or another +non-route directory. + +## Native Stack + +Keep workspace identity and retention outside native-stack `getId` and +`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React +Navigation `getId`, and `getId` has broken Android native-stack/Fabric by +reordering an already-mounted workspace screen. + +## Regression Shape + +Pure helper tests are useful but not enough. The failure mode here is native +route-tree state, so a real regression should launch native with seeded persisted +state: + +1. Seed `paseo:last-workspace-route-selection` with a valid + `{ serverId, workspaceId }`. +2. Launch the native app cold. +3. Assert a real screen is visible, not the blank tree. +4. Assert no `[Layout children]` warning appears. + +The pure policy tests should still enforce the boundary split: + +- root startup with a saved workspace returns `/h/[serverId]`; +- host index with the same saved workspace returns + `/h/[serverId]/workspace/[workspaceId]`; +- host index with no restorable workspace returns `/open-project`. + +## Checklist + +Before landing route changes: + +- [ ] Did you change `packages/app/src/app`? Re-read this file. +- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`. +- [ ] Did an app-wide route return to a workspace? Use + `navigateToHostWorkspaceRoute()`. +- [ ] Did you add a route? Register it in the layout that directly owns it. +- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree. +- [ ] Did native show a blank screen without a crash? Suspect route ownership + before stores, themes, or rendering. diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 5bee5c4ad..46ba5d8fb 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,10 +1,39 @@ -import { Redirect, useLocalSearchParams } from "expo-router"; -import { buildOpenProjectRoute } 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; - // COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11. - 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/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index bfa3fe2b6..b9a60d5e8 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveStartupBlocker, resolveStartupNavigationReady, + resolveHostIndexRoute, resolveStartupRoute, shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, @@ -250,7 +251,7 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "splash" }); }); - it("restores the saved workspace only after the host registry proves the host exists", () => { + it("enters the host boundary for saved workspace restore after the host registry proves the host exists", () => { expect( resolveStartupRoute({ ...baseIndexInput, @@ -258,7 +259,7 @@ describe("resolveStartupRoute", () => { workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, workspaceSelectionStatus: "exists", }), - ).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" }); + ).toEqual({ kind: "redirect", href: "/h/server-1" }); }); it("does not restore a saved workspace after workspace hydration proves it is missing", () => { @@ -357,3 +358,55 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "redirect", href: "/welcome" }); }); }); + +describe("resolveHostIndexRoute", () => { + it("restores the remembered workspace when the host index opens 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 global project selection when the remembered workspace is proven missing", () => { + expect( + resolveHostIndexRoute({ + serverId: "server-saved", + workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" }, + workspaceSelectionStatus: "missing", + }), + ).toEqual("/open-project"); + }); + + it("opens global 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("/open-project"); + }); + + it("opens global project selection when no workspace is remembered", () => { + expect( + resolveHostIndexRoute({ + serverId: "server-saved", + workspaceSelection: null, + workspaceSelectionStatus: "unknown", + }), + ).toEqual("/open-project"); + }); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 240eaf11e..cf3ea0344 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -174,6 +174,20 @@ export function resolveWorkspaceSelectionStatus(input: { 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 buildOpenProjectRoute(); +} + function isIndexPathname(pathname: string) { return pathname === "/" || pathname === ""; } @@ -198,12 +212,11 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St shouldRestoreWorkspaceSelection(input) && hostExists(input.hosts, input.workspaceSelection.serverId) ) { + // Native cold launch must enter the host boundary first. The host index + // owns workspace restore after its local dynamic params exist. return { kind: "redirect", - href: buildHostWorkspaceRoute( - input.workspaceSelection.serverId, - input.workspaceSelection.workspaceId, - ), + href: buildHostRootRoute(input.workspaceSelection.serverId), }; }