url-driven-layout.md38.8 KBView on GitHub

Introduced 1 production defect in 180 days, median 12 days to fix.

# URL-Driven Layout (persistent shell · chat-context as source · no viewStack)

## 1) Introduction — goal, present state, future state

We want the layout to fall out of **one state**: the content area is always `rail · context · other`, where
the **context (left) column is the active chat thread's `selectedArtifact`** — the single item currently
selected from that thread's multi-item context set (a file, a conversation, an email) — falling back to the
route's own content (mail list / pipeline) when nothing is selected. This `selectedArtifact` is the
*displayed / open-on-screen* item — decoupled from the chat's **committed** context set (`chatContext.items[]`,
managed explicitly per `chat-context-set.md`): opening something sets the display pointer only and never
auto-attaches it. The **other (right) column is NOT always the chat**: it's the **calendar agenda** when
browsing the base mail inbox / `/agenda` at rest, and `chat` everywhere else (`/pipeline`, `/crm`, `/agent`,
`/brain`, or anything open). Everything else is *presentation* over that one state: two tiny per-route
functions decide the right column (`selectSidebar` → chat | agenda) and which column is wide (`primary` →
chat on `/agent`/`/brain`, context elsewhere). This makes `/agent` and "`/mail` + open email" the **same**
`{context, chat}` pair with the emphasis flipped, not two different surfaces. Opening, clicking a committed
chip, and the browser back button all just move that one display pointer, mirrored to the URL for deep-links;
`isConversationOpen`/`isThreadOpen`/… become derived from `selectedArtifact.kind`; `viewStack` disappears;
`GlobalCanvas` and the dead `/crm` route are removed entirely;
and — critically for perf — the heavy chat mounts **once** in a persistent root shell so top-level route
switches stop remounting it. Today none
of this holds: `AppShell` (which mounts the 1188-line, 45-hook `EmbeddedCedarChat`) is a **per-route
wrapper in 13 layouts**, so every `/mail ↔ /pipeline ↔ /crm ↔ /agent` switch remounts the whole chat;
"what's open" lives in `viewStack` (a deduped set of ≤3 view *types*) with three fighting URL-sync
components and a `setNavigationPage` that wipes the stack on page change (so the conversation badge lands
on `/agent?threadOpen`); and the per-thread `selectedArtifact` (already the right idea — it *is* the toggle
over the chat's context set, [messagesSlice.ts:209](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts))
exists **in parallel**, only driving the `/agent` display panel. We make `selectedArtifact` the source of
truth for the context column everywhere, hoist `AppShell` to the root, project `selectedArtifact` to/from
the URL with one adapter, derive the `isXOpen` flags, make Escape a `navigate(-1)`, delete `viewStack` /
`pushView` / `popView` / `primaryContext` / `openConversationContext`, and remove `GlobalCanvas` and the
`/crm` route entirely.

## 2) Present state

### 2.1 Architecture diagram

```text
  app/(routes)/layout.tsx  (root — LeftRail + <Outlet/> only)
  ┌────────────┐   <Outlet/> → a per-route layout.tsx, EACH wrapping in <AppShell> (13 of them):
  │ LeftRail   │   ┌──────────────────────────────────────────────────────────────────────┐
  │ (persists) │   │ /mail, /pipeline, /crm, … → <AppShell> … </AppShell>                   │
  └────────────┘   │        AppShell → ChatColumn → EmbeddedCedarChat (1188 lines, 45 hooks) │
                   └──────────────────────────────────────────────────────────────────────┘
        route switch  ⇒  route layout UNMOUNTS (chat destroyed) + next MOUNTS (fresh chat)  ⟵ LAG

  TWO parallel "what's shown" systems:
   (1) uxSlice.viewStack (ViewType[])  ── pushView/popView ──► isThreadOpen/isConversationOpen/…
       setNavigationPage(page): page changed ⇒ viewStack = []          (≈30 readers + ≈9 direct)
       + 3 URL-sync components (path⇄page, ?threadOpen, ?conversationId) fight each other
   (2) messagesSlice: threadMap[id].chatContext.items  (the chat's multi-item context SET)
       threadMap[id].selectedArtifact  (which ONE is displayed) ── getDisplayArtifact() ──► /agent panel
       ▲ ONLY drives the /agent DisplayArtifactPanel; not connected to viewStack or the URL.
```

### 2.2 Step-by-step walkthrough

**Problem A — the route-switch remount (lag).**

1. 13 route layouts each wrap in `<AppShell>` ([mail/layout.tsx](apps/mail/app/(routes)/mail/layout.tsx), …); the root ([app/(routes)/layout.tsx:55](apps/mail/app/(routes)/layout.tsx)) has only `LeftRail` + `<Outlet/>`.
2. `AppShell` → `ChatColumn` → `EmbeddedCedarChat` (1188 lines, 45 hooks, [EmbeddedCedarChat.tsx](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)).
3. `/mail → /pipeline` swaps the route branch → the chat unmounts and remounts; 45 hooks re-init, message list rebuilds, connection re-establishes — the lag on every top-level switch.

**Problem B — the parallel context systems (the badge bug).**

4. The chat already owns a context set + a selected item: `getDisplayArtifact()` returns
   `threadMap[activeThreadId].selectedArtifact ?? { kind: 'agenda' }` at
   [messagesSlice.ts:223](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) — but this only
   feeds the `/agent` [DisplayArtifactPanel.tsx](apps/mail/modules/home/components/DisplayArtifactPanel.tsx).
5. Meanwhile "open" state lives in `viewStack`. Badge → `openConversationContext(id)` at
   [layoutSlice.ts:71](apps/mail/modules/ux/layout/layoutSlice.ts) → `setNavigationPage('home')` clears
   `viewStack` at [uxSlice.ts:524](apps/mail/modules/ux/uxSlice.ts), `navigate('/agent')` leaves
   `?threadOpen`, and `ThreadConversationUrlSync` ([thread-conversation-url-sync.tsx:65](apps/mail/modules/ux/components/thread-conversation-url-sync.tsx)) re-pushes `thread`.
   - Data after: `{ "url": "/agent?threadOpen=thr_1", "viewStack": ["thread"], "selectedArtifact": {conversation, conv_9} }` — the two systems disagree, and the page jump also triggered Problem A.

## 3) Designed state

### 3.1 Architecture diagram

```text
  app/(routes)/layout.tsx  (root — persists across ALL routes)
  ┌────────────┐  ┌──────────────────────── AppShell (mounts ONCE) ─────────────────────────┐
  │ LeftRail   │  │   [ context ]                 │            [ other ]                      │
  │ (persists) │  │   selectContext(store,route)  │   selectSidebar: chat | agenda            │
  │            │  │   <Outlet/> lives inside here │   (ChatColumn persists across routes)     │
  │            │  │  ◄──────────── primary(route) decides which column is WIDE ─────────────► │
  └────────────┘  └───────────────────────────────────────────────────────────────────────────┘
      route switch ⇒ only <Outlet/> content changes; chat + rail never remount. ✓

  SOURCE OF TRUTH (the shared STATE) = the active chat thread (synchronous zustand, no router):
  ┌──────────── messagesSlice.threadMap[activeThreadId] ────────────┐
  │ chatContext.items[]      ← the context SET (file, conversation, email)  ← agent's working set
  │ selectedArtifact {kind,id}| null   ← the ONE displayed (the toggle)     ← drives the context column
  └───────────────┬──────────────────────────────────────────────────┘
                  │  selectContext(store, route):  selectedArtifact ? {kind,id} : routeDefault(route)
                  │  selectSidebar(route, open):   open ? chat : chatFirst(route) ? chat : agenda   ← per-route PRESENTATION
                  │  primary(route):               chatFirst(route) ? 'chat' : 'context'            ← per-route PRESENTATION
                  ▼                                          derived: isConversationOpen = kind==='conversation', …
        activeContext (renders context column AND owns keyboard/Escape)       activeView removed — it's activeContext
        (/agent = [agenda|chat], chat primary;  /mail+open = [email|chat], context primary;  /mail base = [list|agenda])
                  │  ⇅  one LayoutUrlSync (projection only, async)
                  ▼
        THE URL  /<route>?conversation|?email|?file|?slack=<id>  ?chat=<threadId>
        open / navigate / click a committed chip → setSelectedArtifact (DISPLAY only) → adapter pushes URL
        back / deep-link → popstate → adapter sets selectedArtifact → re-render
        (committing to chatContext.items[] is a SEPARATE explicit + action — opening never attaches)

  Deleted: viewStack, pushView/popView/clearViews, setNavigationPage stack-clearing, 2 of 3 URL-syncs,
           ViewStackManager stack logic, primary/primaryContext, openConversationContext, per-route <AppShell>,
           and GlobalCanvas ENTIRELY (component + activeCanvasId/isGlobalCanvasOpen/open|close|dismissGlobalCanvas).
```

### 3.2 Step-by-step walkthrough

**A. Route switch `/mail → /pipeline`.** `AppShell`+`ChatColumn`+`EmbeddedCedarChat` live in the root, above
`<Outlet/>` — only the `<Outlet/>` (pipeline page) mounts. The chat's 45 hooks keep their state.

**B. Click a committed context chip** (from the chat's `chatContext.items[]`). The active thread has
`items = [file_1, conv_9, email_thr_1]`; clicking `conv_9` →

1. **`setSelectedArtifact({ kind: 'conversation', id: 'conv_9' })`** ([messagesSlice.ts:209](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts)) — synchronous, **display only** (does not touch `items[]`).
2. **`selectContext(store, route)`** returns `{ kind: 'conversation', id: 'conv_9' }` → the context column
   renders `ConversationView(conv_9)`; derived `isConversationOpen = true`.
3. **`LayoutUrlSync`** mirrors it: `navigate('/pipeline?conversation=conv_9')` (path preserved, async).

**C. Open a conversation from a list** (badge / cmd+K / row click) — **transient (Decision 1)**. It calls
`setSelectedArtifact({conversation, conv_9})` — the same display pointer as B — and **does NOT add it to
`chatContext.items[]`**. It also sets `activeConversationId = conv_9` so `ConversationView` (which reads
that pointer) renders the right conversation; `selectedArtifact.id` and `activeConversationId` co-move for
a conversation. The conversation shows in the context column and (per `chat-context-set.md`) is surfaced to
the agent only as an *ambient* "open on screen" hint — it commits to the set only on an explicit `+` /
`manage-context.add`.

**E. Open a conversation on `/agent`, then back → the agenda (the source).** `/agent` at rest has no
`selectedArtifact`, so `selectContext('home', …)` returns `{ kind: 'agenda' }` — the context column is the
calendar agenda, URL `/agent`.

1. Open `conv_9` (badge / cmd+K) → `setSelectedArtifact({conversation, conv_9})` + `activeConversationId`.
   `selectContext` now returns the conversation → the context column renders `ConversationView(conv_9)`.
2. **`LayoutUrlSync` pushes** `/agent?conversation=conv_9` (`history:'push'`), so a history entry exists.
   ```json
   { "url": "/agent?conversation=conv_9", "selectedArtifact": { "kind": "conversation", "id": "conv_9" },
     "activeConversationId": "conv_9" }
   ```
3. **Back** → `popstate` pops to `/agent` (no `?conversation`) → `LayoutUrlSync` sets `setSelectedArtifact(null)`
   + `activeConversationId=null` → `selectContext` falls back to `{ kind: 'agenda' }` → **the agenda calendar
   returns**. This is why opening must go through the URL push, not a bare store write — the agenda is the
   `/agent` source that back restores.

**D. Back button (general).** `popstate` → `LayoutUrlSync` reads the previous URL → `setSelectedArtifact(null)`
(+ clears `activeConversationId`) → `selectContext` falls back to the route default (pipeline list, `/agent`
agenda, …). Escape does the same via `navigate(-1)`. Committed `items[]` are untouched — back only changes
the *display* pointer.

**D2. Back with no entry to pop → strip the param, stay on the route.** `navigate(-1)` is only correct
when the entry we're standing on is the one the *open* pushed. Three cases leave in-app history behind
that has nothing to do with the open artifact: a **deep-link / cold load** (nothing to pop), a **reload**
while an artifact is open (the tab keeps its history index, but the pushed entry's document is gone), and
a param that arrived by **`replace`** (no entry was ever created). Popping in any of those walks off the
route — the user asked for `/pipeline` and got the page they were on ten minutes ago.

So the close path is: pop **iff** the current entry is a recorded artifact entry; otherwise **close in
place** — clear the artifact, keep the path. `/pipeline?conversationId=abc` → `/pipeline` either way.

- `LayoutUrlSync` records what each history index has open (`recordArtifactEntry`); an index is poppable
  when a *forward* navigation created it AND it opened an artifact the entry behind it did not have, and
  it stops being poppable when the param clears. It records regardless of who pushed — its own store→URL
  write, or a direct `navigate('/home?conversationId=…')` from the chat / a notification deep-link.
  Module-scoped, so a reload correctly starts empty.
  - The recorder reads the open artifact off **`window.location`**, not off the nuqs params its effect
    is triggered by. A route-level open (`navigate('/agent?conversationId=…')` — the conversation badge)
    settles the pathname one commit BEFORE nuqs' search params, so on the first run at the new entry the
    React state still reports the *previous* entry's param. Keying off it recorded nothing, and back out
    of a badge-opened conversation closed in place on `/agent` — dropping the user on the agent home
    instead of the thread they came from.
- `useBackOrClose(closeInPlace)` ([useBackOrUp.ts](apps/mail/modules/ux/layout/useBackOrUp.ts)) is the
  single close affordance: `isArtifactEntry() ? navigate(-1) : closeInPlace()`. `hasInAppHistory()`
  (`idx > 0`) is the weak signal it replaced — it stays only for `useBackOrUp`, where the fallback is a
  hierarchical parent path rather than an in-place close.
- **Clearing an artifact param `replace`s, never pushes.** A push on close stacked a new entry on top of
  the open, so the browser back button re-opened the thing the user had just closed.

**F. Compose is a thread artifact; send leaves it by going back; undo-send brings it back.** A new
compose has no provider thread, so its `threadData` entry is keyed by the synthetic
`draftSessionId-<uuid>` minted by `draftSlice` — and that key is what goes in the URL:
`?threadOpen=draftSessionId-<uuid>`. `ThreadDisplay` renders a single-draft thread as
`ComposeDisplay`, so a compose and a reply are the same artifact on the same param (the agent's
accept-draft path already opens compose this way). `ThreadDataSync` skips the fetch for such a
key — there is no provider thread behind it.

1. **Send** finishes the surface, so `ComposeDisplay` leaves it the way the back gutter would:
   `navigate(-1)` when the compose owns the pushed `?threadOpen` entry, else an in-place close
   (the `/mail` overlay never pushed one). The user lands back on the list / conversation /
   agenda they came from instead of on an empty pane.
2. **Undo** (`performUndo`) re-pushes it: `restoreUndoneDraft` writes the held-back content onto
   the draft row (the store copy trails the editor by one debounced autosave, and a compose
   session's optimistic-send rollback never ran), then `openThread(storeKey)` reopens the surface
   and `LayoutUrlSync` mirrors `?threadOpen` back into the URL. A send fired from an open
   conversation restores `?conversationId` instead — the draft lives in that timeline.

### 3.3 Schema

Full schema:

```ts
// apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts — REUSED (the source of truth)
interface MessageThread {
  id: string;
  chatContext?: {
    // COMMITTED context set — explicit, hydrated, agent's working set. Managed only by manage-context /
    // user attach (chat-context-set.md). NOT touched by opening/navigating. Rendered as chips / side panel.
    items?: ContextItem[];
    primaryConversation?: { id: string; name?: string };
  };
  // The DISPLAYED / open-on-screen artifact — the AMBIENT "what you're looking at" (chat-context-set.md's
  // ambient signal). Decoupled from items[]: opening sets this and NOT items[] (Decision 1 = transient).
  selectedArtifact?: { kind: ContextKind; id: string } | null;
  // …messages, status, …
}
type ContextKind = 'conversation' | 'email_thread' | 'slack_thread' | 'file';
setSelectedArtifact(sel): void;   // existing — writes the ACTIVE thread's slot only (display); never items[]
getDisplayArtifact(): { kind: ContextKind; id: string } | { kind: 'agenda' };   // existing
```

```ts
// apps/mail/modules/ux/layout/selectContext.ts — NEW. Pure. Reads the active thread + route (not the URL).
export type ActiveContext =
  | { kind: 'conversation'; id: string }
  | { kind: 'email'; id: string }
  | { kind: 'slack'; id: string }
  | { kind: 'file'; id: string }
  | { kind: 'agenda' }          // no artifact, /agent
  | { kind: 'route' };          // no artifact, content route (renders the route <Outlet/>: mail list, crm, …)

// (1) selectContext — the shared STATE: the context column AND the keyboard owner (activeView collapsed in).
export const selectContext = (route: string, s): ActiveContext => {
  const a = s.getDisplayArtifact();               // active thread's selectedArtifact ?? {agenda}
  if (a.kind !== 'agenda') return mapArtifact(a); // conversation/email_thread/slack_thread/file → ActiveContext
  return route === 'home' ? { kind: 'agenda' } : { kind: 'route' };   // per-route default (Decision 3)
};

// (2) selectSidebar + (3) primary — pure PRESENTATION over the route (NOT the shared state). The right
//     column is NOT always chat: agenda while browsing a base list route with nothing open; and `primary`
//     flips which column is wide, so /agent and "/mail + open" are one model with the emphasis swapped.
export type SidebarKind = 'chat' | 'agenda';   // no 'none' — full-page routes opt out of AppShell entirely
const CHAT_FIRST = new Set(['home', 'knowledgeBase']);              // /agent, /brain
// agenda ONLY on the base mail inbox / agenda at rest (Decision 3). /pipeline, /crm, everything else → chat.
const AGENDA_AT_REST = new Set(['mail', 'agenda']);
export const selectSidebar = (route: string, s): SidebarKind => {
  const open = s.getDisplayArtifact().kind !== 'agenda';           // something in selectedArtifact?
  return !open && AGENDA_AT_REST.has(route) ? 'agenda' : 'chat';   // only base mail/agenda rest → agenda
};
export const primary = (route: string): 'chat' | 'context' =>
  CHAT_FIRST.has(route) ? 'chat' : 'context';                       // wide column

// isXOpen flags — DERIVED from selectedArtifact.kind (same read API for the ~30 consumers):
//   isConversationOpen = selectedArtifact?.kind === 'conversation'
//   isThreadOpen       = selectedArtifact?.kind === 'email_thread'
// Keyboard/Escape owner = selectContext(route, s).   (GlobalCanvas removed entirely — Decision 2)
```

```ts
// apps/mail/modules/ux/layout/LayoutUrlSync.tsx — NEW. Projection only (thread ⇄ URL); never read by render.
export interface LayoutUrlParams {
  conversation?: string;   // ⇄ selectedArtifact {conversation}  AND activeConversationId (co-move; ConversationView reads it)
  email?: string;          // ⇄ selectedArtifact {email_thread}   (renamed from `threadOpen`)
  file?: string;           // ⇄ selectedArtifact {file}
  slack?: string;          // ⇄ selectedArtifact {slack_thread}
  chat?: string;           // ⇄ activeThreadId (the chat thread)   (Decision 4 = yes, in URL)
}
// At most one of conversation/email/file/slack is present (selectedArtifact is single).
// store → URL uses history:'push' (each open is a back-able entry); clearing the param on back restores the
// route default (e.g. /agent → agenda). URL → store sets BOTH selectedArtifact and activeConversationId.

// DELETED: viewStack, pushView/popView/clearViews, getActiveView/getNonCanvasActiveView/isViewInStack/isViewOnTop,
//   setNavigationPage's stack-clearing; navigation-page-url-sync.tsx, thread-conversation-url-sync.tsx,
//   conversation-open-url-sync.tsx, thread-open-url-sync.tsx; layoutSlice.ts::openConversationContext;
//   resolveContext.ts::selectPrimaryContext; the ActiveView enum + selectActiveView.
//   GlobalCanvas ENTIRELY (Decision 2): GlobalCanvas.tsx, activeCanvasId/isGlobalCanvasOpen, openGlobalCanvas/
//   closeGlobalCanvas/dismissGlobalCanvas, ?canvas, and their consumers.  The /crm ROUTE (Decision 3).
```

Relationship diagram:

```text
   ┌──────── messagesSlice.threadMap[activeThreadId] (SOURCE OF TRUTH) ────────┐
   │ chatContext.items[] ──► COMMITTED set (explicit; agent context; chips)     │  ← NOT the layout URL
   │ selectedArtifact {kind,id} ──► the DISPLAYED / open-on-screen (ambient)    │  ← drives the layout
   │   (opening sets this only; committing to items[] is a separate + action)   │
   └───────────────┬───────────────────────────────────────────────────────────┘
      selectContext │ (pure; + route default when null)         derived: isConversationOpen / isThreadOpen …
                    ▼
             activeContext  ──renders──► context column        ──owns──► keyboard / Escape
                    │  id ─FK─► conversationsSlice / threadSlice / documentsSlice   (1:1 by kind)
                    │  ⇅  LayoutUrlSync (projection: async both ways)
                    ▼
        THE URL  /<route>?conversation|email|file|slack=<id>  ?chat   ── back/deep-link ──► popstate
```

## 4) Implementation phases

> **Execution status (2026-07-13).** Landed, tested, pushed (jest-green, typecheck-clean): **Phase 1**
> (persistent shell), **Phase 2** (pure `selectArrangement` fold) + the **cross-arrangement no-remount**
> (chat mounts once across /agent↔/pipeline↔/brain via a stable `ResizableSidebar` `flex` mode),
> **Phase 5** (`open*` → `selectedArtifact`, additive), and the **`/crm` route removal** from Phase 7.
> **UPDATE — all core phases landed.** Phase 3 (`LayoutUrlSync`), Phase 4 (flags derived from
> `selectedArtifact`), Phase 6 (viewStack readers migrated), and the **`viewStack` deletion** are all
> pushed. `viewStack`/`pushView`/`popView`/`clearViews` are gone; `isThreadOpen`/`isConversationOpen`
> derive from the active thread's `selectedArtifact`; `isGlobalCanvasOpen` is a standalone flag;
> `getActiveView`/`isViewInStack`/`isViewOnTop` are kept as flag-derived selectors. **Deferred:**
> `GlobalCanvas` deletion (overlay is alive — see ⚠️) and Escape=`navigate(-1)` (per-view Escape still
> works). ⚠️ **RUNTIME-VERIFY in-app:** draft review, canvas layering, Escape, hotkey scopes, URL
> round-trips / back button — none are covered by jest. The historical notes below are kept for context:
> - The repo has **no e2e/Playwright/Cypress** harness; jest (jsdom) only covers pure resolvers and
>   mocked components. Nothing headless can confirm URL round-trips, the browser back button,
>   hotkey scopes, or rendered columns — the exact behaviors 3–7 change.
> - The existing URL syncs ([thread-conversation-url-sync.tsx](apps/mail/modules/ux/components/thread-conversation-url-sync.tsx),
>   [conversation-open-url-sync.tsx](apps/mail/modules/conversations/components/conversation-open-url-sync.tsx),
>   [thread-open-url-sync.tsx](apps/mail/modules/threads/threadList/components/thread-open-url-sync.tsx))
>   carry two concerns the doc's simplified `LayoutUrlSync` omits: the **`conversationSection`**
>   deep-link suffix (`?conversationId=abc/files/docId`) and **hotkey-scope** management
>   (`mail-list`/`conversation-list`). Replacing them requires re-homing both, or deep-links and
>   keyboard scopes silently break.
> - The phases are **one interlocking change**, not four independent ones: Phase 4 (derive
>   `isConversationOpen` from `selectedArtifact`) needs Phase 5 (open* sets `selectedArtifact`) needs
>   Phase 3 (URL sync + param renames). Landing any one alone yields a build that compiles and passes
>   jest but is broken at runtime.
> Net: "typecheck + jest green" is a false gate for 3–7. Do them route-by-route with the app running
> (matches the load-bearing warning in `project-layout-slice-refactor` memory). The `LayoutUrlSync`
> design below stands — it just needs to also carry `conversationSection` and the list-scope handoff.

### Phase 1 — Persistent root shell (the perf win, independent)

**Goal:** `AppShell` + `ChatColumn` mount once at the root; top-level route switches only swap `<Outlet/>`.

- [x] Move `<AppShell>` into [app/(routes)/layout.tsx](apps/mail/app/(routes)/layout.tsx) wrapping `<Outlet/>`, beside `LeftSidebarContent` — via a new [PersistentShell](apps/mail/modules/ux/layout/PersistentShell.tsx) gate (see divergence)
- [x] `AppShell` renders `[ children(=Outlet) ] [ ChatColumn ]` (kept the current `chatDominant`/`brain` arrangement reads for now; only the mount location changed)
- [x] Remove the `<AppShell>` wrapper from the 13 per-route layouts (mail, pipeline, crm, calendar, conversations, brain, home, meetings, gallery, outbound, statistics, reports, agentExecutions) — they render only their content
- [x] Confirm `EmbeddedCedarChat` is not remounted on route change (mount-counter sentinel — see test)

> **Divergence:** full-width routes (`/settings`, `/developer`, `/agenda`, `/linkedin`, …) must opt out of the shell (Decision 3), so the root can't unconditionally wrap `<Outlet/>`. Introduced [PersistentShell](apps/mail/modules/ux/layout/PersistentShell.tsx) + a pure [shellRoutes.ts](apps/mail/modules/ux/layout/shellRoutes.ts) (`isShellRoute(pathname)`, `SHELL_ROUTE_SEGMENTS`) derived from `app/routes.ts`. The root renders `isShellRoute ? <AppShell><Outlet/></AppShell> : <Outlet/>`. Switching between two shell routes keeps the same `AppShell`/`ChatColumn` mounted; crossing to a full-width route unmounts it (chat isn't wanted there). NOTE: `AppShell` still branches internally (chatDominant/brain/else), so the chat still remounts when crossing *between* those arrangements — Phase 2 collapses that. Within the 11 "else" routes the chat already persists.

**Tests:**

- [x] Add `apps/mail/tests/modules/layout/persistentShell.test.tsx`: swap the routed child, assert the chat instance is preserved (mount counter increments once); + `apps/mail/tests/modules/ux/layout/shellRoutes.test.ts` (pure truth table)
- [x] `pnpm --filter mail test persistentShell` (38 tests green)

### Phase 2 — `selectContext` (state) + `selectSidebar`/`primary` (presentation)

**Goal:** the two columns are a pure function of the active thread + route; no URL reads in render. The right column is chat / agenda / none, and `primary` decides which is wide.

- [x] `selectContext` reading `getDisplayArtifact()` + route default already exists as [resolveContext.ts](apps/mail/modules/ux/layout/resolveContext.ts) (`computeContext`/`selectContext`/`ContextContent`) — reused, not re-created (see divergence)
- [x] `selectSidebar(s)` exists ([selectSidebar.ts](apps/mail/modules/ux/layout/selectSidebar.ts)); added [selectArrangement.ts](apps/mail/modules/ux/layout/selectArrangement.ts) with `computeArrangement`/`computePrimary`/`isChatFirst`/`selectArrangement`/`selectPrimary`
- [x] `AppShell` branch selection now reads the pure `selectArrangement` (behavior-preserving fold of `chatDominant`/`brain`/`else`); no route-string checks in render
- [x] Keyboard/Escape owner selector = `selectContext` (exists); the Escape=`navigate(-1)` rewrite is Phase 6

> **Divergence:** (1) The repo already had `resolveContext.ts::selectContext(s)` returning `ContextContent` (agenda | brainHome | conversation | emailThread | slackThread | file) — reused rather than adding a parallel `selectContext.ts`/`ActiveContext`. (2) The doc's 2-value `primary` with `CHAT_FIRST = {home, knowledgeBase}` would make **Brain chat-dominant**, but Brain's actual UX is home-wide with chat as a ~40% right sidebar (`primary: context`). So the fold is a **3-value** `ColumnArrangement` (`chatDominant` | `brain` | `default`); `computePrimary` maps `chatDominant→chat`, `brain`/`default`→`context`, and `CHAT_FIRST = {home}` only. (3) `/agenda` is not a real `NavigationPage` and opts out of the shell, so agenda-at-rest applies to `/mail` only — `selectSidebar` unchanged.

**Tests:**

- [x] `resolveContext.test.ts` covers each `selectedArtifact` kind → `ContextContent` + route defaults (agenda on home, brainHome on brain); `selectSidebar.test.ts` covers mail-at-rest → agenda / open → chat
- [x] Added `apps/mail/tests/modules/ux/layout/selectArrangement.test.ts`: arrangement per (page, hasOpenArtifact); `primary` chat on chat-dominant, context on brain/default
- [x] `pnpm --filter mail test` (layout suite: 72 green)

### Phase 3 — One bidirectional `LayoutUrlSync` (URL as projection)

**Goal:** `selectedArtifact` (+ `?chat`) ⇄ URL via one adapter; the three old sync components are gone; URL never drives render.

- [x] Added [LayoutUrlSync.tsx](apps/mail/modules/ux/layout/LayoutUrlSync.tsx): thread/conversation ⇄ URL (loop-guarded refs), mirrors the open artifact onto `selectedArtifact` (kind-guarded so closing one never wipes another), owns the list hotkey scope derived from the route
- [x] Mounted once in [app/(routes)/layout.tsx](apps/mail/app/(routes)/layout.tsx); removed the ~10 per-route mounts; deleted `thread-conversation-url-sync.tsx`, `conversation-open-url-sync.tsx`, `thread-open-url-sync.tsx` (+ the crm re-export)
- [x] Param names KEPT (`threadOpen`/`conversationId`+section) — see divergence

> **Divergence:** (1) kept the existing param names (`threadOpen`/`conversationId`) rather than renaming to `email`/`conversation`/`file`/`slack`, so deep-links and the direct readers (ConversationSearchCommandBar, CompanyExplorer) keep working untouched; `?file`/`?slack`/`?chat` are deferred until those artifact kinds render. **`?slack=<containerKey>` has since landed** (slack-parity Phase 5) now that the unibox's ChannelThreadView renders a Slack channel — the container key is the Slack channel id (`C…`/`D…`), and `useOpenChannelItem` is the source that puts the artifact in the slot. `?file` is still deferred. (2) `NavigationPageUrlSync` stays separate (path⇄page is orthogonal and works). (3) list scope is derived from the pathname in one place instead of a per-mount prop.

**Tests:**

- [x] Added `apps/mail/tests/modules/ux/layout/layoutUrlSync.test.ts` (`listScopeForPath` truth table). The URL round-trip / back button / hotkey scope are runtime-only (no e2e) — verify in-app.

### Phase 4 — Derive the `isXOpen` flags from `selectedArtifact.kind`

**Goal:** `isThreadOpen`/`isConversationOpen` are selectors over `selectedArtifact`; the ~30 readers untouched.

- [ ] Convert the flags to derived selectors (`kind==='email_thread'` / `kind==='conversation'`); same store-read API
- [ ] Stop `pushView`/`popView` writing the flags

**Tests:**

- [ ] Add `apps/mail/tests/modules/ux/layout/derivedFlags.test.ts`: `selectedArtifact` → flags
- [ ] `pnpm --filter mail test derivedFlags`

### Phase 5 — Route the `open*` actions through `selectedArtifact` (display only — transient)

**Goal:** the ~55 callers keep their action names; the bodies set `selectedArtifact` (display **only**, per Decision 1) instead of `pushView`. Opening never attaches to `chatContext.items[]`.

- [x] `openConversation` ([conversationsSlice.ts](apps/mail/modules/conversations/slice/conversationsSlice.ts)) → `setSelectedArtifact({conversation,id})` (already sets `activeConversationId`). **Additive** — kept `pushView` so the flag/draft-review system stays intact until viewStack is retired (Phase 7)
- [x] `openThread` ([threadSlice.ts](apps/mail/modules/threads/threadList/store/threadSlice.ts)) → `setSelectedArtifact({email_thread,id})`; additive alongside `pushView`
- [ ] Point the badge + cmd+K at `openConversation(id)`; repoint meetings page off `pushView`/`popView` — deferred to the app-running pass (touches the concurrently-edited conversations slice)
- [x] Attach-to-set stays its own explicit path (untouched)

> **Divergence:** kept `pushView` alongside the new `setSelectedArtifact` (additive) rather than dropping it, so `isThreadOpen`/`isConversationOpen` and the draft-review `isViewInStack` logic keep working until viewStack is deleted in Phase 7. The `?conversation`/`?email` URL push (which makes back restore the source) lands with Phase 3's `LayoutUrlSync`.

**Tests:**

- [x] Added `apps/mail/tests/modules/ux/layout/openPaths.test.ts`: `openConversation`→ `selectedArtifact` conversation + `activeConversationId`, **`items[]` unchanged**; `openThread`→ `email_thread` artifact, `items[]` unchanged

### Phase 6 — Migrate direct `viewStack` readers + Escape

**Goal:** the ~9 direct readers use `activeContext`; Escape = browser back.

- [ ] Migrate `viewStack.at(-1)`/`.includes()` readers to `activeContext`/`isXOpen`: [brain/page.tsx](apps/mail/app/(routes)/brain/page.tsx), [EmbeddedCedarChat.tsx](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx), [PipelineView.tsx](apps/mail/modules/pipeline/components/PipelineView.tsx), [active-view-display.tsx](apps/mail/components/ui/active-view-display.tsx), the two debugger tabs, `threadSlice` internal reads
- [ ] `ViewStackManager` Escape → `navigate(-1)`; list-scope from `activeContext.kind` (`route`/`agenda`); remove `popView` ([view-stack-manager.tsx:34](apps/mail/lib/hotkeys/view-stack-manager.tsx))

**Tests:**

- [ ] Update `apps/mail/modules/cedar-os/__tests__/chatComponents/*viewStack*` specs to `activeContext`
- [ ] `pnpm --filter mail test`

### Phase 7 — Delete `viewStack` + `GlobalCanvas` + the `/crm` route

**Goal:** the stack, the canvas overlay, and the dead route are gone; `[context][chat]` from `selectContext`.

- [ ] Delete `viewStack`, `pushView`, `popView`, `clearViews`, `getActiveView`, `getNonCanvasActiveView`, `isViewInStack`, `isViewOnTop` from [uxSlice.ts:154](apps/mail/modules/ux/uxSlice.ts); remove the page-change clearing at [uxSlice.ts:524](apps/mail/modules/ux/uxSlice.ts)
- [ ] Remove `openConversationContext` ([layoutSlice.ts:71](apps/mail/modules/ux/layout/layoutSlice.ts)) + `selectPrimaryContext`
- [ ] ⚠️ **BLOCKED — `GlobalCanvas` is NOT dead (Decision 2 needs revisiting).** The 2026-07-13 inventory shows the overlay is the LIVE delivery mechanism for three features: the **Ctrl+Q calendar overlay** (`global-hotkeys.tsx` → `openGlobalCanvas('calendar')` → `GlobalCanvas` renders `CalendarView`), **open-canvas-from-chat** (`EmbeddedCedarChat.tsx:758`), and **CRM-filter-result canvas** (`CRMFilterRenderer.tsx`). `isGlobalCanvasOpen` is also entangled with **agent-context inclusion** ([agentContextSlice.ts:730](apps/mail/modules/cedar-os/src/store/agentContext/agentContextSlice.ts): `canvasIsVisible = isGlobalCanvasOpen || homeViewCanvasIds.includes(activeCanvasId)`) and `ContextBadgeRow` visibility. Deleting the overlay removes those features and breaks agent context. **Do not delete until the design says how canvases surface without the overlay** (HomeView tabs only? a replacement flag?).
- [x] **Removed the `/crm` route (Decision 3):** deleted `app/(routes)/crm/` (layout + page), its `routes.ts` entry, `'crm'` from `NavigationPage` + the path maps, the `shellRoutes` segment, and the dead `page==='crm'` branches (EmbeddedCedarChat situational buttons, CRMFilterRenderer `canOpenCanvas`, thread-display `isCRMView`). `/crm` had **no nav link** (direct-URL only). `modules/crm/*` (`ConversationView`, `crm-table`, canvas components — 60+ consumers) **kept**. Commit `f21ef2bc`.
- [ ] `DisplayArtifactPanel` is the shared context renderer for all routes; `useRouteChatThread` governs `/agent` freshness only
- [ ] `grep -rn "viewStack\|pushView\|popView\|openConversationContext\|primaryContext\|selectActiveView\|GlobalCanvas\|isGlobalCanvasOpen" apps/mail/{modules,components,app}` returns empty

**Tests:**

- [ ] `pnpm --filter mail typecheck && pnpm --filter mail test`
- [ ] Manual: `/mail↔/pipeline` switch has no chat remount; committed-chip click flips the left column + URL; badge→`?conversation` in place (chat on the side, transient — not added to `items[]`); **on `/agent`, open a conversation → URL gains `?conversation` → back returns to the agenda calendar**; `/mail` base = agenda sidebar, `/pipeline` = chat

## Decisions (resolved)

1. **Navigate-open is transient, not attach.** Opening a conversation/email sets `selectedArtifact` (the
   display / open-on-screen pointer) **only**; it is NOT added to `chatContext.items[]`. Committing to the
   agent's set stays an explicit action (ghost-chip `+` / `manage-context.add`), exactly the ambient-vs-
   committed split in [chat-context-set.md](apps/server/docs/chat-context-set.md). So `selectedArtifact`
   (layout) and `items[]` (agent context) are decoupled.
2. **`GlobalCanvas` overlay is removed; the canvas data model stays.** No `?canvas`, no
   `isGlobalCanvasOpen`, no `open/close/dismissGlobalCanvas`, no overlay layer — deleted with its
   consumers (Phase 7). The canvas *data* model (`canvasSlice.canvasesById`, `agentCanvas`, HomeView
   canvas tabs, and `activeCanvasId` as the tab selector) is **kept** — it's separate and still used.
   ⚠️ **REOPENED 2026-07-13:** the code inventory disproved the "overlay is dead" premise — it's the
   live mechanism for the Ctrl+Q calendar overlay, canvas-from-chat, and CRM-filter canvas, and
   `isGlobalCanvasOpen` feeds agent context. This decision needs the design to first specify how those
   surface without the overlay. Deletion is on hold (see the ⚠️ in Phase 7).
3. **Sidebar at rest:** agenda **only** on base `/mail` and `/agenda`; `/pipeline`, `/crm`, and everything
   else get **chat**. The **`/crm` route is removed** (Phase 7) but `modules/crm/*` (`ConversationView`,
   `crm-table`) is **kept** — reused elsewhere. (Scope resolved 2026-07-13.) Full-page routes (`/settings`,
   `/developer`) opt out of `AppShell` (full-width).
4. **`?chat` is in the URL** — the active chat thread id is projected, so chats are shareable and the
   per-context-thread back behavior works through history.