The app shell is two independent overlay scopes side by side. The left scope — the routable area — holds the nav, main content, and all overlays (drawers, sheets, palettes) that should cover the page but not the AI. The right scope — the observer area — is the AI sidebar. It reads the current page’s context and can propose URL-state changes, but owns no route state itself.

Chrome structure

<Chrome>                          providers, NavigationContext, AiAssistantContext
  <RoutableArea>                  ModalBoundary — overlays portal here
    <NavRail />
    <Main>
      <Outlet />                  routed page content
    </Main>
    <Inspector />                 portals into RoutableArea
    <Palette />
  </RoutableArea>
  <ObserverArea>                  separate ModalBoundary, shrink-0
    <AiChat />                    only when open + entitlement
  </ObserverArea>
</Chrome>

The sidebar sits outside the routable area as a direct sibling. Overlays opened inside the routable area can never bleed into the sidebar — they portal into their own ModalBoundary.

Chrome providers

<Chrome
  navigation=     // back-button wiring for deep-linked views
  aiAssistant= // undefined = no AI trigger on this page
>
  {children}
</Chrome>

Chrome mounts notify outlets (toast, confirm, interrupt) and TooltipProvider — singletons that need the full tree below them.

AiAssistantContext is consumed by the nav rail to show/hide the AI trigger button. The sidebar itself is toggled by plain useState in the root layout — not a URL param.

Observer area: reading without owning

The sidebar never receives props from route components. The root layout runs usePageContext — which reads the current URL and the TanStack Query cache — and passes the result down as pageContext:

const pageContext = usePageContext({ registry, dynamicMatchers });
<AiChat pageContext={pageContext} onFilter={onFilter} onNavigate={onNavigate} />

Route components don’t know the sidebar exists.

Page context registry

Each route subtree registers a mapping of path → context config:

const ITEMS_CONTEXT: PageContextRegistry = {
  "/items": {
    flag: "release-ai-context-items",  // feature flag gates the surface
    getConfig: ({ search, queryClient }) => ({
      title:       "Items",
      isQuery:     false,
      queries:     [{ query: buildItemsQuery(search.get("filter")), result: null }],
      meta:        { activeFilter: search.get("filter"), totalCount: readCachedCount(queryClient) },
      suggestions: ["Filter to items created last week", "What needs attention?"],
      tools: {
        filter:   { schema: { type: "object", properties: { filter: { type: "string" } } } },
        navigate: { targets: [{ to: "/items/$id", label: "Item detail" }] },
      },
    }),
  },
};

queries lists queries the page has already run. Results are read from TanStack cache — no extra fetches.

For routes whose context shape depends on search params, use a DynamicContextMatcher instead of a static key.

Tool calls: observer proposes, routable area executes

The AI agent proposes filter/sort/navigate actions. The root converts them to router.navigate() calls — all URL writes, all back-button reversible:

onFilter={(action) => navigate({
  search: (prev) => action.mode === "replace" ? action.params : { ...prev, ...action.params },
  state: true,
  resetScroll: false,
})}

autoRun actions apply immediately. requireUserConfirmation actions render a button in the chat panel.

SSE: container + presentational

Every AI-streaming component splits in two:

Container — calls the SSE hook, derives state, passes primitives down
Presentational — accepts explicit props (streaming, content, error), renders all states

The presentational component has no hooks, so every state is directly testable with explicit props without mocking the hook.

// Derive display state from the event array
const content          = events.filter(e => e.type === "content").map(e => e.content).join("");
const interruptMessage = events.find(e => e.type === "interrupt")?.content ?? null;
const sseErrorMessage  = events.find(e => e.type === "error")?.content    ?? null;
const thinkingMessage  = events.filter(e => e.type === "thinking").at(-1)?.content ?? null;

streaming stays true for the entire fetch. States branch on event content after streaming goes false. Shimmer shows while !hasContent — disappears the moment the first content event arrives, not when the stream closes.