# Split layout and navigation

> Concept: Route encoding, component registry entries, split history, navigation causes, popovers, and desktop versus mobile split behavior.

- Repository: macro-inc/macro
- GitHub: https://github.com/macro-inc/macro
- Human docs: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e
- Complete Markdown: https://grok-wiki.com/public/docs/macro-inc-macro-bb988e1a448e/llms-full.txt

## Source Files

- `js/app/packages/app/component/Root.tsx`
- `js/app/packages/app/component/split-layout/SplitLayoutRoute.tsx`
- `js/app/packages/app/component/split-layout/componentRegistry.tsx`
- `js/app/packages/app/component/split-layout/layoutManager.ts`
- `js/app/packages/app/component/split-layout/layout.ts`
- `js/app/packages/app/component/split-layout/tests/layoutManager.test.ts`

---

---
title: "Split layout and navigation"
description: "Concept: Route encoding, component registry entries, split history, navigation causes, popovers, and desktop versus mobile split behavior."
---

The app renders workspace content through a Solid Router catch-all split route whose path segments encode the visible split contents as `type/id` pairs, while `createSplitLayout` owns mounted content, per-split history, focus events, popovers, URL synchronization, and desktop/mobile layout decisions.

## Route encoding

A split URL is parsed as alternating path segments:

```text
/component/inbox/md/doc_123
└─ type ─┘└ id ┘└type┘└ id  ┘
   split 1       split 2
```

| URL | Decoded content |
| --- | --- |
| `/component/inbox` | one registered component split: `{ type: 'component', id: 'inbox' }` |
| `/md/doc_123` | one block split for block or alias type `md` and id `doc_123` |
| `/md/doc_123/component/search` | two visible splits |
| `/` or an invalid empty pair list | default split `{ type: 'component', id: 'inbox' }` |

`decodePairs()` consumes pairs until a missing `type` or `id` is found. Non-component types are resolved through the block alias registry; aliases keep `aliasContext` so the URL can re-encode the alias instead of only the resolved base block type.

<Note>
Only the current visible split contents are URL encoded. Component `params`, block `params`, per-entry `state`, and split history are runtime state and are not restored from the path after a full reload.
</Note>

## Route mounting and URL sync

`LAYOUT_ROUTE` uses `path: '/*splits'` and passes `props.params.splits?.split('/') ?? []` into `SplitLayoutContainer`. The root route table also maps top-level app paths such as `/inbox`, `/agents`, `/mail`, `/documents`, `/tasks`, `/channels`, `/calls`, and `/files` to the same layout component, while `DEFAULT_ROUTE` is `/component/inbox`.

`SplitLayoutContainer` performs two-way synchronization:

| Direction | Behavior |
| --- | --- |
| Manager to URL | When `splitManager.getUrlSegments()` differs from the router segments, the container navigates to `/${segments.join('/')}`. |
| URL to manager | When router segments change externally, the container calls `splitManager.reconcile(decodedPairs())`. |
| Excluded splits | `getUrlSegments()` omits splits hidden by the current exclusion filter, used by mobile background panels. |

## Split content model

The layout manager treats all mounted content as `SplitContent`:

```ts title="js/app/packages/app/component/split-layout/layoutManager.ts"
type SplitContent =
  | {
      type: BlockName | BlockAlias;
      id: string;
      params?: BlockComponentProps[BlockName];
      aliasContext?: BlockAliasContext;
      state?: EntryState;
    }
  | {
      type: 'component';
      id: string;
      params?: Record<string, unknown>;
      state?: EntryState;
    };
```

Block content is mounted through the global block orchestrator. Component content is resolved through the split component registry.

## Component registry entries

Component splits use `{ type: 'component', id }`. The `id` must be registered in `componentRegistry.tsx`; otherwise `resolveComponent()` throws `Component '<name>' not registered`.

Registered app-level component ids include:

| Component id | Runtime behavior |
| --- | --- |
| `unified-list` | Redirects in-place to `{ type: 'component', id: 'inbox' }`. |
| `inbox`, `agents`, `mail`, `documents`, `tasks`, `channels`, `calls`, `folders` | Auth-gated `SoupView` presets with page-view tracking. |
| `search` | Auth-gated `SoupView` that can receive `initialQuery`, `initialFilters`, and `initialClientFilters` when opened programmatically. |
| `loading` | Loading block placeholder. |
| `channel-compose`, `email-compose`, `task-compose` | Compose surfaces. |
| `settings` | Settings panel wrapper. |
| Local/dev-only ids | Debug and internal tools gated by `LOCAL_ONLY` or `DEV_MODE_ENV`. |

<Warning>
A component opened with runtime `params` works during programmatic navigation, but those params are not encoded into the URL. Reloading the same path recreates the component with no params.
</Warning>

## Opening and replacing splits

Most callers use `useSplitLayout()` rather than the manager directly.

| Helper | Behavior |
| --- | --- |
| `openWithSplit(content, options)` | General navigation entry point. On mobile, `preferNewSplit` is forced to `false` by the hook. |
| `replaceOrInsertSplit(content, referredFrom)` | Opens in the current panel when called inside a split panel, activates it, and records a referral cause. |
| `replaceSplit({ content, mergeHistory, referredFrom })` | Replaces the current panel and forces `preferNewSplit: false`. |
| `insertSplit(content, referredFrom)` | Prefers a new split and activates it. |
| `popoverSplit(content)` | Creates a temporary dialog-backed split instead of a URL-backed panel. |
| `resetSplit()` | Resets the current panel to the default split. |

`openWithSplit()` follows this order:

1. A registered navigation interceptor may consume the navigation. Mobile swipe layout uses this.
2. Unless `allowDuplicate` is true, an existing visible split with the same `type` and `id` is activated and returned.
3. If no explicit handle is provided, the active split becomes the target handle.
4. The target handle is replaced when `preferNewSplit` is false or the resize zone cannot fit another `400px` panel.
5. Otherwise a new split is appended.

`replaceWhenFull` defaults to enabled. Setting `replaceWhenFull: false` allows callers to request a new split even when `canAppendSplit()` reports insufficient space, but desktop rendering still depends on the resize zone.

## Split history and navigation causes

Each split owns an independent `History<SplitContent>` with `push`, `merge`, `back`, `forward`, `replaceCurrent`, `goToIndex`, and `remove`.

| Action | History effect | `NavigationCause` |
| --- | --- | --- |
| Initial split creation | Pushes initial content. Optional `initialHistory` is pushed first. | `fresh` |
| `replace()` with `mergeHistory: false` | Pushes a new entry, forking away any forward entries. | `fresh` |
| `replace()` with `mergeHistory: true` | Merges into the current entry. | `replace` |
| `goBack()` | Moves to the previous entry. | `history-back` |
| `goForward()` | Moves to the next entry. | `history-forward` |
| `goToEntry(predicate)` | Jumps to the closest matching prior entry, then closest forward entry. | `history-back` or `history-forward` |
| `removeFromHistory(predicate)` | Removes matching entries and reattaches the current surviving entry. | `replace` |

Components can read the latest cause with `useNavigationCause()`. This is intended for behavior that differs between fresh navigation and restoration, such as suppressing auto-focus after back/forward.

`useEntryState(key, { default })` stores component-owned state on the current history entry. Registered captors run before navigation away, then the captured state is mirrored back onto `split.content.state`.

## Referral metadata

`referredFrom` records where navigation originated. It is stored on `SplitState` and exposed through `SplitHandle.referredFrom()`.

Allowed values are:

```ts
'list-view' | 'kommand-menu' | 'mention' | 'attachment' | 'launcher' |
'sidebar' | 'dock' | 'entity-actions-menu' | 'hotkey' | 'quick-access' |
'file-upload' | 'search' | null
```

Use the closest existing value when adding a navigation caller. If the source is not meaningful to downstream behavior or analytics, pass `null`.

## Popover splits

Popover splits are temporary mounts managed by `createPopoverSplit()` and rendered by `PopoverSplitRenderer`.

A popover split:

- creates a `popover-...` id;
- acquires a focus lock before state updates;
- mounts the same block/component content model as normal splits;
- renders inside a `Dialog` and `Panel`;
- provides a stub `SplitHandle` with `isPopover() === true`;
- has no URL segments, no history navigation, and `lastNavigationCause() === 'fresh'`;
- closes on `escape` or dialog close;
- releases the focus lock and removes its map entry after a short animation delay.

Popover content still receives `SplitPanelContext`, so components that depend on panel context can render inside the dialog. Do not expect URL persistence, back/forward history, or split resizing inside a popover.

## Desktop layout behavior

Desktop split rendering uses a horizontal `Resize.Zone` with `Resize.Panel` children. Each panel has `minSize={400}` and renders a `SplitPanel` containing:

- a split-specific hotkey DOM scope;
- `SoupContextProvider`;
- split header and toolbar slots;
- the mounted block or registered component element;
- spotlight support;
- focus restoration and active split tracking.

Focus changes inside a panel activate that panel after a short debounce. Insert and remove events explicitly move focus to the inserted split or the nearest remaining split.

Relevant split hotkeys include:

| Hotkey | Behavior |
| --- | --- |
| `cmd+\` or `\` | Create a new inbox split when space allows. |
| `cmd+escape` / `opt+escape` | Go home or close split depending on current content and split count. |
| `opt+[` / `opt+]` | Per-split history back/forward. |
| `shift+escape` | Toggle spotlight when multiple splits exist. |
| `shift+h` / `shift+arrowleft` | Focus split left. |
| `shift+l` / `shift+arrowright` | Focus split right. |

## Mobile layout behavior

There are two mobile-related checks:

| Check | Effect |
| --- | --- |
| `isMobile()` in `useSplitLayout()` | Forces `preferNewSplit` to `false` for callers using the helper. |
| `isNativeMobilePlatform()` in `SplitLayoutContainer` | Switches rendering from desktop `Resize.Zone` to the native mobile two-slot swipe layout. |

The native mobile layout uses slot A and slot B. One slot is foreground and the other may hold a background split for swipe-back. The background split is excluded from URL encoding, duplicate detection, and content lookup.

Mobile navigation behavior differs from desktop:

- `createMobileSwipeLayout()` registers a navigation interceptor.
- Non-`mergeHistory` navigation is handled as forward navigation into the background slot, then promoted to foreground after animation.
- Swipe-back promotes the background slot, removes the old foreground split, and lazily mounts the promoted split’s previous history entry as the next background split.
- `mergeHistory` navigation bypasses the interceptor and uses normal replace behavior.
- `MobileDock` uses `mergeHistory` when switching between component/list views so dock tab switches do not create swipe-back entries.

## Reconciliation contracts

`reconcile(newSplits)` is used for URL-driven changes. It compares the visible split key sequence with the decoded URL sequence, preserves excluded splits unchanged, and rebuilds changed visible positions. When a visible split exists at the same index, the new split reuses that split id to keep panel identity stable at that position.

Run the layout manager tests when changing reconciliation, history, or URL behavior. The test suite covers URL-state reconciliation, block-to-component replacement, and browser-back-like ordering scenarios.

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `No split manager found` | A split helper ran before `SplitLayoutContainer` registered the global manager. | Call split helpers only under the app layout route or guard for missing manager. |
| `Component '<id>' not registered` | A URL or caller opened `{ type: 'component', id }` without a registry entry. | Add `registerComponent(id, factory)` in the split component registry. |
| URL opens inbox instead of expected content | The path did not contain a complete `type/id` pair. | Use `/component/<id>` for registered components or `/<blockType>/<id>` for blocks. |
| Component state disappears after reload | Runtime `params`, entry state, and history are not URL encoded. | Persist durable state outside split runtime state or encode it in a supported route/content id. |
| New block does not open in another split | Duplicate non-component content is prevented unless allowed. | Pass `allowDuplicate: true` only when duplicate mounts are intentional. |
| Mobile background split is missing from URL | The mobile swipe layout excludes the background slot. | Treat this as expected; only foreground-visible content is URL encoded. |
