* fix(app): open notification agents once in their current workspace Keep tap ownership across Activity and React remounts, clear native responses, and resolve agent workspaces through existing navigation after startup settles. Co-authored-by: Kassam Khoja <kassam@shopwrk.com> * fix(app): hydrate agent targets with the compact sidebar closed Hold directory demand for the agent route while resolving its workspace; the compact sidebar does not supply demand before it opens. * fix(app): keep deferred workspace opens in the layout model Key pending tab opens by workspace so redirects and retained screens cannot lose or consume another workspace's target. Legacy open URLs seed this state at their route boundary. * docs(app): document notification tap ownership and native replay * fix(ci): route feature-owned Electron sources to desktop checks * refactor(app): separate notification API from React integration * fix(app): consume pending opens before painting the restored tab A legacy open link showed the previously focused terminal for a frame before switching to the requested terminal. Seed and consume the pending open in layout effects so a ready target switches before paint. * fix(e2e): focus the visible retained terminal surface --------- Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
9.8 KiB
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, orh/[serverId]/index. packages/app/src/app/h/[serverId]/_layout.tsxowns the host leaves with relative screen names:index,workspace/[workspaceId]/index,agent/[agentId],sessions,open-project,settings,plugin/[pluginId]/[surfaceId], andplugin/[pluginId]/[contributionKind]/[contributionId].
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 restore is based on the last navigated workspace, not current connection status. Do not redirect to another online host just because the remembered host is still connecting or offline; the workspace screen owns that offline/loading state.
This split is deliberate. The host layout must mount first so native local dynamic params exist before any nested workspace leaf is selected.
Error Recovery
The root error boundary lives above ExpoRoot in src/root-app.tsx. Reload
remounts the router at /open-project, preserving saved workspace layouts and
the remembered selection. Do not move the boundary inside the root layout:
catching a render error there unmounts the navigator, so the recovery button
cannot reliably dispatch a navigation reset. Starting recovery at / would
restore the same workspace and could repeat the crash.
App-Wide Route Hops
When app-wide routes such as /new, /settings, or /sessions navigate back
into a host workspace, use navigateToWorkspace(). Do not make the caller
branch on its current route.
Pass only serverId and workspaceId for normal attention-aware navigation.
When the action names a specific tab, pass it as target; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
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 when a host route is
already mounted, or Expo Router can append extra hidden workspace deck entries.
The workspace navigation helper inspects the mounted navigation state to make
that decision; if no host route is mounted yet, it falls back to ordinary route
navigation. Both paths wait for the root navigation container to be ready. The
workspace navigation owner keeps the latest pending intent across ref
re-registration and applies it on the container's ready event; routes must not
add their own readiness retries.
Those hidden entries are not harmless: composer floating panels can measure against the wrong deck and disappear offscreen. Follow the retained panel measurement rules for components that intentionally remain mounted while hidden.
Hidden host routes may keep their local params while an app-wide route is
foregrounded. Active-workspace observers must prefer the current pathname and
only use local param fallback during cold mount (/ or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct settings/hosts/[serverId]/plugins/[pluginId]/[screenId] leaf;
Back returns to that host's Plugins page.
Settings detail routes are separate siblings on purpose. Keep
settings/[section], the host routes, the projects index, and project detail as
distinct route names. router.dismissTo() ultimately matches stack entries by
route name. A single catch-all Settings route would make project detail and the
projects index the same route; Back would update params in place and leave a
phantom detail entry underneath. The host routes also stay outside the
store-ready protected group so a cold host deep link can survive daemon startup.
Do not collapse this topology with a catch-all, getId, or
dangerouslySingular workaround.
Agent Targets
Notifications and agent URLs name a host and agent. Both use
navigateToAgent(), which reads the agent's current workspace from the store
or routes through /h/[serverId]/agent/[agentId]. The agent route waits for
the named host, demands its directory, and waits for workspace and layout
hydration before opening the tab. Keep the stable agent URL free of a workspace
hint. Notifications owns tap delivery and native lifecycle
constraints.
When a workspace target cannot open yet, navigateToWorkspace() records a
pending open in the layout model under that host and workspace key. The owning
workspace screen consumes it once after hydration. Archived-workspace recovery
reads the same pending target, so startup redirects cannot discard it and a
retained screen cannot consume another workspace's request.
Legacy ?open= links seed this model state at the matched route boundary and
then lose the query parameter. Internal navigation does not use the query as a
handoff. Read matched route params locally; global search params let retained
workspace screens observe requests for other workspaces.
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.
Use ThemedStack from packages/app/src/navigation/themed-stack.tsx for every
Expo Router stack. React Navigation otherwise paints each native stack screen
with its light default background. A screen-level wrapper can hide that surface
while settled, but Android may expose it for one frame when navigation crosses
from a nested stack to its parent stack. This is especially visible when an
app-wide route such as /new opens from a dark workspace.
Do not read the active theme with useUnistyles() in a layout to build
screenOptions. ThemedStack keeps that third-party prop theme-reactive through
a small withUnistyles boundary without subscribing the route tree itself to
every Unistyles runtime update.
Navigators keep their identity across appearance changes. ThemedStack remounts
each ordinary screen's content below the native stack when appearance tokens change.
Workspace screens own retained panel lifetimes and update appearance at their
leaves through screenOwnedAppearance (Explorer). A
keyed wrapper above a stack remounts the stack itself, and Android crashes when
that happens while a FragmentManager transaction is running, which settings
hydration at startup makes likely. A screen that mounts a nested navigator (the
root stack's h/[serverId]) is passed in nestedNavigatorScreens so the nested
stack owns its own screens. See unistyles.md.
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:
- Seed
paseo:last-workspace-route-selectionwith a valid{ serverId, workspaceId }. - Launch the native app cold.
- Assert a real screen is visible, not the blank tree.
- 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 a route return to a workspace? Use
navigateToWorkspace()and pass atargetwhen the action names a specific tab. - 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.