mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix mobile launch with a saved workspace
This commit is contained in:
@@ -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 <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
115
docs/expo-router.md
Normal file
115
docs/expo-router.md
Normal file
@@ -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.
|
||||
@@ -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 <Redirect href={buildOpenProjectRoute()} />;
|
||||
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,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user