# Navigation, Tabs & Route Isolation

> useNavigation().push and AppLink are the only allowed navigation primitives in shared code. Desktop distinguishes three route categories: session routes (real tabs under WorkspaceRouteLayout), transition flows (create workspace, accept invite — rendered as WindowOverlay state, never real routes), and error states (never rendered; stale tabs are healed by dropping the tab group). The navigation adapter detects cross-workspace push and calls switchWorkspace instead of letting the memory router navigate inside the wrong tab. Web uses ordinary Next.js routes plus searchParams; desktop memory router + tab-store keep each workspace’s tabs isolated by construction.

- Repository: multica-ai/multica
- GitHub: https://github.com/multica-ai/multica
- Human wiki: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf
- Complete Markdown: https://grok-wiki.com/public/wiki/multica-ai-multica-fd1dd916d3bf/llms-full.txt

## Source Files

- `packages/core/navigation/store.ts`
- `apps/desktop/src/renderer/src/stores/tab-store.ts`
- `apps/desktop/src/renderer/src/platform/navigation.tsx`
- `apps/web/platform/navigation.tsx`
- `packages/core/platform/core-provider.tsx`

---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [packages/core/navigation/store.ts](packages/core/navigation/store.ts)
- [apps/desktop/src/renderer/src/stores/tab-store.ts](apps/desktop/src/renderer/src/stores/tab-store.ts)
- [apps/desktop/src/renderer/src/platform/navigation.tsx](apps/desktop/src/renderer/src/platform/navigation.tsx)
- [apps/web/platform/navigation.tsx](apps/web/platform/navigation.tsx)
- [packages/core/platform/core-provider.tsx](packages/core/platform/core-provider.tsx)
- [packages/views/navigation/index.ts](packages/views/navigation/index.ts)
- [packages/views/navigation/context.tsx](packages/views/navigation/context.tsx)
- [packages/views/navigation/app-link.tsx](packages/views/navigation/app-link.tsx)
- [packages/views/navigation/types.ts](packages/views/navigation/types.ts)
- [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx](apps/desktop/src/renderer/src/components/workspace-route-layout.tsx)
- [apps/desktop/src/renderer/src/routes.tsx](apps/desktop/src/renderer/src/routes.tsx)
- [apps/desktop/src/renderer/src/stores/window-overlay-store.ts](apps/desktop/src/renderer/src/stores/window-overlay-store.ts)
- [apps/desktop/src/renderer/src/components/tab-content.tsx](apps/desktop/src/renderer/src/components/tab-content.tsx)
- [apps/desktop/src/renderer/src/App.tsx](apps/desktop/src/renderer/src/App.tsx)
- [apps/web/app/[workspaceSlug]/layout.tsx](apps/web/app/[workspaceSlug]/layout.tsx)
- [packages/core/platform/workspace-storage.ts](packages/core/platform/workspace-storage.ts)
- [packages/core/paths/reserved-slugs.ts](packages/core/paths/reserved-slugs.ts)
</details>

# Navigation, Tabs & Route Isolation

`useNavigation().push()` and `<AppLink>` are the only navigation primitives permitted in shared business code (`packages/views/`). All platform-specific routing (Next.js App Router vs. react-router memory routers) is hidden behind the `NavigationAdapter` interface supplied by each app's `NavigationProvider`. This boundary keeps view components portable across web and desktop while allowing desktop to solve a hard problem: multiple independent workspaces must coexist in one window with fully isolated tab state, yet a single `push()` call from a shared component must never leak a tab across workspace boundaries or persist a transition flow as a real tab.

The desktop implementation distinguishes three route categories by construction. Session routes live under per-tab memory routers inside `WorkspaceRouteLayout`. Transition flows (`/workspaces/new`, `/invite/*`, onboarding, invitations list) are intercepted and rendered as `WindowOverlay` state, never as routes. Error states for stale workspace slugs are never rendered as pages; `WorkspaceRouteLayout` heals them by dropping the entire tab group. The navigation adapter performs the classification on every `push`/`replace` before any router sees the path. Web, by contrast, uses ordinary Next.js file-system routes plus `searchParams`; a missing workspace slug produces an explicit `NoAccessPage` because the URL is user-visible and shareable.

## Navigation Primitives and the Adapter Contract

Shared code may only call:

- `const { push, replace, back, pathname, searchParams, openInNewTab, getShareableUrl, prefetch } = useNavigation();`
- `<AppLink href="...">` (which calls `push` and wires meta/ctrl-click to `openInNewTab` when available)

The `NavigationProvider` (from `@multica/views/navigation`) wraps the platform adapter and adds a React `useTransition` so callers receive a pending signal during route commit. The adapter interface lives in `packages/views/navigation/types.ts`.

```tsx
// packages/views/navigation/context.tsx:19
const wrapped = useMemo<NavigationAdapter>(() => ({
  ...value,
  push: (path) => startTransition(() => value.push(path)),
  replace: (path) => startTransition(() => value.replace(path)),
}), [value]);
```

`AppLink` prevents default and delegates exclusively to the adapter:

```tsx
// packages/views/navigation/app-link.tsx:22
e.preventDefault();
onClick?.(e);
push(href);
```

Desktop and web each supply a concrete `NavigationAdapter` that satisfies the same surface. No file under `packages/views/` may import `next/navigation`, `react-router-dom`, or any tab-store symbol.

**Sources:** [packages/views/navigation/context.tsx:1-50](), [packages/views/navigation/app-link.tsx:1-60](), [packages/views/navigation/types.ts:1-45]()

## Desktop Route Categories

The navigation adapter (`apps/desktop/src/renderer/src/platform/navigation.tsx`) classifies every path before delegating:

1. **Session routes** — workspace-scoped paths of the form `/{slug}/{page}` (issues, projects, settings, etc.). These become real tabs under an independent `createMemoryRouter(appRoutes)` whose root element is `WorkspaceRouteLayout`. Each tab owns its own `DataRouter`; the tab bar only ever shows the active workspace's group.

2. **Transition flows** — pre-workspace or one-shot actions:
   - `/workspaces/new`
   - `/onboarding`
   - `/invitations`
   - `/invite/:id`
   These are never routes. `tryRouteToOverlay` opens the corresponding `WindowOverlay` entry and, if necessary, forces the underlying tab router to `/`. The `WindowOverlay` component renders a full-window chrome above the entire tab system.

3. **Error / stale states** — a persisted tab whose slug no longer resolves (workspace deleted, access revoked, or migration artifact). `WorkspaceRouteLayout` detects the missing workspace in its query effect and calls `tabStore.validateWorkspaceSlugs()`. The entire group is removed and its routers disposed; no error page is ever mounted on desktop.

```tsx
// apps/desktop/src/renderer/src/platform/navigation.tsx:197
if (tryRouteToOverlay(path, active?.router)) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
```

**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:52-140](), [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx:70-110](), [apps/desktop/src/renderer/src/stores/window-overlay-store.ts:1-40]()

## Cross-Workspace Push Handling

When a `push` target slug differs from `activeWorkspaceSlug`, the adapter must not let the current memory router navigate. Instead it calls `tabStore.switchWorkspace(targetSlug, path)`:

```tsx
// apps/desktop/src/renderer/src/platform/navigation.tsx:102
function tryRouteToOtherWorkspace(path: string): boolean {
  const targetSlug = extractWorkspaceSlug(path);
  if (!targetSlug) return false;
  const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
  if (targetSlug === activeWorkspaceSlug) return false;
  switchWorkspace(targetSlug, path);
  return true;
}
```

`extractWorkspaceSlug` returns `null` for reserved slugs (`login`, `workspaces`, `invite`, etc.) so those paths are never misinterpreted as workspace navigation. The same check exists in both `DesktopNavigationProvider` (shell-level) and `TabNavigationProvider` (per-tab).

`switchWorkspace` either creates a fresh group with a default tab or activates an existing tab whose path matches `openPath`. Because tab groups live in a `Record<slug, WorkspaceTabGroup>`, a push can never deposit a tab into the wrong workspace's array.

**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:102-109](), [apps/desktop/src/renderer/src/stores/tab-store.ts:260-320](), [apps/desktop/src/renderer/src/stores/tab-store.ts:177-190]()

## Tab Isolation by Construction

`useTabStore` (Zustand + persist) maintains:

```ts
// apps/desktop/src/renderer/src/stores/tab-store.ts:39
interface TabStore {
  activeWorkspaceSlug: string | null;
  byWorkspace: Record<string, WorkspaceTabGroup>; // slug → { tabs, activeTabId }
  ...
}
```

Each `Tab` holds a stable `DataRouter` created by `createTabRouter(initialPath)`. `TabContent` renders every tab of the active group inside an `<Activity>` (visible vs. hidden) so hidden tabs preserve DOM and React state but only the active router receives events. When the active workspace changes, the previous workspace's tabs unmount entirely; cross-workspace state preservation is an explicit non-goal.

`validateWorkspaceSlugs` (called from the stale-slug effect) disposes routers for removed groups and picks the first remaining slug as the new active workspace. The TabBar and TabContent simply observe the store; they never contain cross-workspace logic.

**Sources:** [apps/desktop/src/renderer/src/stores/tab-store.ts:33-80](), [apps/desktop/src/renderer/src/stores/tab-store.ts:541-560](), [apps/desktop/src/renderer/src/components/tab-content.tsx:28-55]()

## Web Contrast

Web supplies its adapter directly from Next.js:

```tsx
// apps/web/platform/navigation.tsx:19
const adapter: NavigationAdapter = {
  push: router.push,
  replace: router.replace,
  ...
  searchParams: new URLSearchParams(searchParams.toString()),
  getShareableUrl: (p) => window.location.origin + p,
  prefetch: router.prefetch,
};
```

All workspace-scoped routes live under `app/[workspaceSlug]/layout.tsx`. An unknown slug renders `<NoAccessPage/>` (the URL bar makes the error state meaningful). Pre-workspace flows (`/workspaces/new`, `/invite/:id`, `/onboarding`) are ordinary pages under the `(auth)` route group; they never need special interception.

Search params are first-class (`?issue=...`, `?filter=...`) and survive refreshes because they are part of the real URL. Desktop mirrors the active tab's location into the shell `DesktopNavigationProvider` so components outside any `TabNavigationProvider` (ChatWindow, global search) still see correct `searchParams`.

**Sources:** [apps/web/platform/navigation.tsx:19-45](), [apps/web/app/[workspaceSlug]/layout.tsx:70-110]()

## Invariants, Failure Modes, and Dependency Direction

**Invariants**
- Every live workspace group contains ≥1 tab.
- Every tab path is workspace-scoped and passes `sanitizeTabPath`.
- `setCurrentWorkspace(slug, wsId)` is only ever called from `WorkspaceRouteLayout` (desktop) or the equivalent Next.js layout (web) after the workspace has been successfully fetched.
- Shared code never holds a direct reference to a router or to `byWorkspace`.

**What breaks if the design changes**
- Removing the three early returns in the adapter push path would let a `/invite/abc` path become a real tab whose router would then throw when `useWorkspaceId()` is called.
- Flattening `byWorkspace` back into a single `tabs` array would re-introduce the original cross-workspace leakage bug that motivated the refactor.
- Allowing `packages/views/` to import `react-router-dom` would make the desktop memory-router model leak into the shared layer; a future web-only navigation primitive would then be impossible without duplication.

**Dependency direction**
`packages/views/` → `NavigationAdapter` (interface only)  
`apps/desktop/.../platform/navigation.tsx` and `apps/web/platform/navigation.tsx` → concrete adapters + their stores/routers  
`packages/core/platform/workspace-storage.ts` → `setCurrentWorkspace` singleton (read by API client and persisted stores)

**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:192-210](), [packages/core/platform/workspace-storage.ts:34-60](), [apps/desktop/src/renderer/src/components/workspace-route-layout.tsx:90-105]()

## Summary

The navigation system isolates three concerns—shared view contracts, platform route execution, and desktop tab lifecycle—by making the adapter the single point of classification. Desktop's `byWorkspace` map plus the overlay-vs-route distinction guarantees that `useNavigation().push("/beta/issues")` from any shared component either switches the visible workspace group or creates a tab inside the correct group, never both and never neither. Web reuses the same primitives over ordinary Next.js routing because its URL surface makes the extra machinery unnecessary. The architecture therefore satisfies the cross-platform rule while giving desktop the stronger isolation guarantees its window model demands.

**Sources:** [apps/desktop/src/renderer/src/platform/navigation.tsx:253-309](), [packages/views/navigation/context.tsx:30-50]()
