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.