You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/29 07:33:42 UTC

(maka) branch main updated: perf(desktop): give the shell's state the scope of its readers (#4125)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 68b71c57f perf(desktop): give the shell's state the scope of its readers (#4125)
68b71c57f is described below

commit 68b71c57ffe5e6920257a01e016da1b22f34c6da
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Aug 29 15:33:36 2026 +0800

    perf(desktop): give the shell's state the scope of its readers (#4125)
    
    * test(desktop): gate the hooks scoped to the whole shell
    
    AppShellContent calls 40 hooks across 70 call sites, and every one of them
    scopes its state to the entire tree. That is why a session switch produces
    about 19 React commits, 14 of them full-tree renders, of which 96-99% is
    recoverable work (#4109).
    
    The fix is to move call sites into feature providers, one at a time. #3439
    tracks that as a checklist of extractions, but extracting a feature into its
    own slice does not change the scope of any state: Session Navigation is a
    complete model/controller/ui/ports slice whose controller is still invoked
    from this render body. A call site, unlike an extraction, has a definition of
    done — the hook is either called here or it is not.
    
    So count them. The inventory is hand-edited and may only shrink; a migration
    deletes its entry and the same diff shows the gate converging. A hook that
    appears without one fails, which is not a prohibition but a refusal to let it
    happen silently. There is deliberately no --write mode: regenerating the
    inventory on demand would let a new hook be accepted by rerunning a command.
    
    useMemo, useCallback, useRef and useId are ignored. They hold no state and
    subscribe to nothing, so they cannot widen what a render reaches.
    
    The gate runs beside the other install-free checks in CI, since it reads one
    source file and compares it to a committed list.
    
    Generated-by: Claude Code
    
    * refactor(desktop): own the composer mention catalog below the shell
    
    useComposerMentions reloads the Skill catalog on every session switch and on
    every MCP or session change event, several times per switch. Held in
    AppShellContent, each of those reloads set state above the whole tree, so
    repainting two composer popups re-rendered everything under the shell.
    
    Move the call site into a ComposerMentionsProvider. `children` is the element
    AppShell has already built, so React bails out of the subtree and only the
    composers that read the context re-render. The projection itself is unchanged,
    including the fail-closed contextKey derivation and the same-context refresh
    that keeps its settled verdict (#2667).
    
    Owning it below the shell also removes the reason to carry it through the
    workbar: mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading and
    onSearchMentionFiles were threaded through the controller, the host and the
    surface to reach two consumers, and QuoteCompanionPanel now reads the same
    context directly. The file is renamed to composer-mentions.tsx because it is no
    longer a hook module.
    
    This is the first call site to move under the gate added in the previous
    commit: the inventory goes from 40 hooks to 39, and from 70 call sites to 69.
    No behaviour changes — the popups render from the same projection, at the same
    time, for the same surfaces (#4109).
    
    Generated-by: Claude Code
    
    * style: format the app-shell hook gate
    
    Biome's own formatting for the gate script, its tests, and the package.json
    entry. Squashed into the gate commit on merge.
    
    Generated-by: Claude Code
    
    * chore: record composer-mentions.tsx in the Astryx surface inventory
    
    The mention module became a .tsx file when it took ownership of its provider,
    which brings it into the surface inventory's coverage. It renders no Astryx
    components, so the row is an aligned no-op — but the inventory is a
    regenerate-and-diff contract, and a file it cannot see is the failure mode it
    exists to catch. Squashed into the mention commit on merge.
    
    Generated-by: Claude Code
    
    * fix(desktop): count the shell's hooks correctly and keep the composer's absent catalog absent
    
    Three defects found by adversarial review of the gate and the migration it
    shipped with. All three were silent, and two of them made this pull request's
    own numbers wrong.
    
    The gate under-counted. It required a `(` directly after the hook name, so
    every `useState<T>(...)` was invisible — 12 call sites in AppShellContent,
    including `useNewTaskChoice` and `useSessionSettingIntent`, two hooks that were
    not in the inventory at all and passed the gate anyway. It also counted
    `copy.useSkillPrompt(...)` as a hook and a `useUiLocale()` mentioned in a
    comment. The real figure is 42 hooks across 83 call sites, not 39 across 69;
    the delimiter now balances braces instead of stopping at the first `\n}\n`,
    which had been correct only because AppShellContent happens to end the file.
    
    The gate also watched the wrong scope. `AppShell` wraps `AppShellContent` in
    the root providers and holds state of its own, so hoisting a hook one level up
    would have LOWERED the count while widening what it re-renders. Both
    components are counted now.
    
    And `NO_MENTIONS` was not equivalent to no props. Without a provider the
    context handed the composer an empty catalog and a real `onSearchMentionFiles`,
    where it had previously received `undefined` — enough for the Composer to mount
    the mention popup's layer for a surface with nothing behind it, which broke all
    seven cases of the draft-handoff suite. The context defaults to `undefined` and
    consumers read it optionally, so a composer outside the shell sees exactly what
    it saw before.
    
    Also here: the `workbar-boundary` contract now asserts the context read rather
    than the prop names it was written against; `./composer-mentions` gains its
    `.js` specifier, without which Node's ESM resolver could not load the module at
    all; and the provider's memo is built from destructured fields so its
    dependencies are its materials rather than a hand-kept mirror of them.
    
    The inventory is exact rather than a ceiling, and the failure text says so
    instead of instructing the reader to lower the number — advice that a
    mis-count would otherwise turn into a way to empty the gate.
    
    Generated-by: Claude Code
    
    * perf(desktop): give the Session rail its own scope
    
    Switching a Session re-rendered the whole tree about fourteen times, and each
    of those renders reached the Session rail's ~1,000 fibers. Not because any
    component there is expensive — they are individually cheap, and memoising them
    recovered 6% — but because the rail's state was declared above them:
    `useSessionNavigationController()` and the `sessions` / `activeSessionId`
    `useState`s were called in AppShell's render body, so every one of the shell's
    commits was also a commit of the rail.
    
    Move the state to where its readers are.
    
    - `sessions` and `activeSessionId` become one external store, read through
      `useSyncExternalStore` with custom-equality selectors — the mechanism
      `app-shell-session-ui-state.ts` already established in #1985, generalised
      here as `useExternalStoreSelector` so both stores share it.
    - `useSessionNavigationController()` moves out of AppShell into
      `SessionNavigationProvider`, which sits directly above the rail and publishes
      what the rail reads as two contexts: a memoised one for the ~1,000 fibers of
      rows and an unmemoised one for the few dozen fibers of chrome.
    - The rail element itself is a module constant, so the shell rendering does not
      rebuild it and React skips the subtree.
    
    Cross-feature intent stays explicit. Opening a Session still clears the active
    transcript and leaves WorkHub; those remain commands the shell issues, passed
    as `SessionNavigationPorts` and read through a ref published on commit — so the
    rail can call them without their identity being something it re-renders for.
    
    What this makes redundant is deleted, not left behind: `SessionNavigationHost`
    and its fifteen props, the same prop set redeclared across `SessionListPanel`,
    `SessionHistoryList` and `SessionListGroups`, `renderSessionRow`'s eight-entry
    dependency array, and the `useStableActions` hand-stabilisation that existed
    only because the state sat at the top. The hook gate's inventory shrinks with
    it.
    
    Measured in one running instance, alternating configurations and comparing
    paired trials: busy JS per switch drops from 585ms to 397–430ms (27–32%), and
    the inline-style writes the rail's DOM takes drop from 1,696–2,086 to 168–424.
    `session-rail-render-budget.spec.ts` holds that as a contract — a budget on
    what one switch is allowed to touch, plus the assertion that the selection
    moves exactly once and the status badges are not rebuilt underneath it — and
    the probes that produced the numbers ship as `scripts/perf/`.
    
    Generated-by: Claude Code
    
    * fix(desktop): drop the session-navigation exports nothing reads
    
    The rail migration left four exported types with no reader. Knip fails
    the `test` check on them, and each is a real leftover rather than a
    tolerated one:
    
    `SessionNavigationSearchTarget` was declared twice. `session-open-command.ts`
    owns it and uses it; the copy in `use-session-navigation-controller.ts` is
    what the rename left behind, and two declarations of one shape is the kind
    of drift a barrel hides.
    
    `SessionNavigationReads`, `SessionRailProjection` and `SessionNavigationSession`
    were re-exported from the feature barrel for consumers that no longer exist:
    the reads hook returns the first, the provider and the reads hook take the
    other two from their own modules, and the tests reach them through
    `testing.ts`. A barrel entry with no importer widens the feature's public
    surface for nothing.
    
    Generated-by: Claude Code
    
    * refactor(desktop): give the renderer's stores one notification rule
    
    Three stores — session UI state, the session catalog, the rail's geometry
    — each carried their own `listeners` set, `replaceState` and `subscribe`.
    The duplication that matters is not the lines: it is that "swap, then
    notify synchronously, and never schedule it" is load-bearing (#1985's
    terminal-turn handoff reads back the state it announces) and was restated
    per store, with the third copy no longer restating it at all.
    `createObservableState` holds that rule once and the three stores keep
    only what they actually differ in: their state and their commands.
    
    The rail's controller subscribed to that store three times, once per
    field, and then reassembled the three values into an object identical to
    the store's own state. Every field is read, so the split bought no
    granularity, and the store already replaces its state only when a field
    moved — the identity is the comparison. One subscription through
    `selectRailLayout`, which is how the shell's own read was already written.
    That retires `selectRailCollapsed`, `selectRailWidth`, `selectRailViewMode`
    and the `SessionNavigationLayout` mirror of `SessionRailLayoutState`.
    
    `createInitialSessionCatalogState` and the controller's `initialState`
    parameter go with them: nothing ever passed one. They were copied from
    the shape of #1985's controller, whose equivalents tests do use.
    
    Generated-by: Claude Code
    
    * test(desktop): fold the rail render budget into the one rail contract
    
    #4110 landed `session-rail-render-contract.spec.ts` on main while this
    branch carried `session-rail-render-budget.spec.ts`. They are one
    authority — what a single session switch is allowed to cost the Session
    rail — so they become one spec rather than two that must be kept
    agreeing.
    
    The contract's shape wins. Attributing writes to rows closes the hole a
    total-only budget leaves: a regression that re-renders the whole rail
    exactly once stays under any total generous enough not to flake, but it
    cannot touch two rows. Its counter also carries a liveness lower bound
    and polls to quiet instead of sleeping a fixed 3s.
    
    What the budget had and the contract did not is the timing half, and that
    moves across: the selection must pass through exactly one row on its way
    to the clicked one, and the status badges must not be torn down and
    rebuilt underneath it. A count says how much was rewritten, not whether
    the user watched it happen.
    
    Given up: the budget ran both grouping modes. The contract's fixture
    seeds one project, and row attribution is independent of how many rows
    are seeded, so the second mode was buying repetition rather than reach.
    
    Generated-by: Claude Code
    
    * refactor(desktop): keep the A/B switch out of product code
    
    `rail-scope-probe.ts` shipped in the renderer so the rail's old and new
    scope could both be reached from one running instance — the only way to
    compare, since renderer timings shift by orders of magnitude between app
    launches while the spread inside one launch is small.
    
    The premise was wrong: the switch does not have to ship. Branch a
    worktree, put the flag in the one place that reads it, alternate inside
    the running instance, delete the worktree. Measurement discipline is
    ours; product code carries none of it. What shipped instead was a branch
    no product path executes and a replica of the defect that would have to
    be maintained against the code it replicates.
    
    `session-switch-busy-js.mjs` loses `--ab` with it and now reports the
    running build only. The README keeps the rule that made the probe seem
    necessary and states how to satisfy it from a throwaway worktree.
    
    Generated-by: Claude Code
    
    * fix(desktop): keep the rail's collapse sentinel out of the stored width
    
    Astryx reports a collapse as `onSizeChange(0)` — from the collapse control and
    from a drag past the threshold alike. Moving the rail's width into
    `sessionRailLayoutStore` moved the call site but not the `width >= minWidth`
    guard that used to sit beside it in `session-navigation-host.tsx`, so that zero
    reached `setWidth`, was clamped to 180, and was persisted 200 ms later. Resize
    to 400, collapse, reload, expand: the rail came back at 180.
    
    The guard belongs to the store, not to a call site. The store is the only
    authority for the width and its persistence, and a call site is exactly what
    moved out from under the old guard.
    
    Reported by an automated review agent operated by @M4n5ter on #4125.
    
    Generated-by: Claude Code
    
    * fix(desktop): count every React hook call form in the convergence gate
    
    The gate counted only unqualified `use[A-Z]` names, so `React.useState(0)` and
    React 19's bare `use(...)` were invisible to a scanner whose entire value is
    that it fails closed — a future subscription could widen the shell's scope with
    this exact inventory still green.
    
    Both forms now count, and `React.useState` counts under `useState`: the
    inventory names the hook, not the syntax that reached it. The qualifier is the
    literal `React.` rather than any identifier because the shell really does call
    `shellCopy.useSkillPrompt(name)`, which a general `<name>.useX(` rule would
    count as a hook. That leaves one hole — `import * as X from 'react'` — and the
    gate closes it by refusing the file, since it cannot resolve the alias and must
    not guess.
    
    The counts are unchanged: 42 hooks, 81 call sites.
    
    Reported by an automated review agent operated by @M4n5ter on #4125.
    
    Generated-by: Claude Code
---
 .github/workflows/ci.yml                           |   9 +
 .../e2e/session-rail-render-contract.spec.ts       |  66 ++-
 .../src/main/__tests__/session-list-layout.test.ts |  44 +-
 .../__tests__/session-navigation-boundary.test.ts  |  15 +-
 .../session-navigation-controller.test.ts          | 181 ++++++---
 .../src/main/__tests__/workbar-boundary.test.ts    |  10 +-
 .../src/renderer/app-shell-command-actions.ts      |   2 +-
 apps/desktop/src/renderer/app-shell-e2e-fixture.ts |   2 +-
 .../src/renderer/app-shell-session-ui-state.ts     |  43 +-
 apps/desktop/src/renderer/app-shell.tsx            | 233 +++++++----
 apps/desktop/src/renderer/chat-composer-region.tsx |  20 +-
 apps/desktop/src/renderer/chat-message-surface.tsx |   6 +-
 ...-composer-mentions.ts => composer-mentions.tsx} |  94 ++++-
 .../renderer/features/session-navigation/README.md |  24 +-
 .../controller/session-open-command.ts             |  52 +++
 .../use-session-navigation-controller.ts           | 267 ++++--------
 .../controller/use-session-navigation-reads.ts     |  79 ++++
 .../renderer/features/session-navigation/index.ts  |  12 +-
 .../model/session-rail-layout-store.ts             | 111 +++++
 .../features/session-navigation/testing.ts         |   5 +
 .../ui/session-navigation-host.tsx                 | 120 ------
 .../ui/session-navigation-provider.tsx             | 216 ++++++++++
 .../workbar/controller/use-workbar-controller.ts   |  10 +-
 .../tools/side-chat/quote-companion-panel.tsx      |  15 +-
 .../renderer/features/workbar/ui/workbar-host.tsx  |   9 -
 .../features/workbar/ui/workbar-surface.tsx        |   9 -
 apps/desktop/src/renderer/live-turn-reconciler.tsx |   4 +-
 apps/desktop/src/renderer/observable-state.ts      |  62 +++
 apps/desktop/src/renderer/session-catalog-state.ts |  92 +++++
 .../src/renderer/use-app-shell-session-list.ts     |  34 +-
 .../src/renderer/use-app-shell-session-ui-reads.ts |  20 +-
 .../renderer/use-app-shell-session-workspace.ts    |  19 +-
 ...-selector.ts => use-external-store-selector.ts} |  31 +-
 apps/desktop/src/renderer/use-project-context.ts   |   7 +-
 apps/desktop/stories/app-shell.stories.tsx         |   6 +-
 apps/desktop/stories/subagent-sessions.stories.tsx |   4 +-
 docs/astryx-surface-file-inventory.md              |   6 +-
 docs/astryx-surface-file-inventory.paths           |   4 +-
 package.json                                       |   1 +
 .../__tests__/session-history-row-actions.test.tsx |  32 +-
 packages/ui/src/__tests__/sidebar-footer.test.tsx  |  24 +-
 packages/ui/src/components.tsx                     |   7 +-
 packages/ui/src/index.ts                           |   6 +-
 packages/ui/src/session-history-list.tsx           |  86 ++--
 packages/ui/src/session-list-panel.tsx             | 126 ++----
 packages/ui/src/session-rail-context.tsx           | 127 ++++++
 packages/ui/src/session-sidebar-nav.tsx            |  24 +-
 .../ui/stories/composite-row-states.stories.tsx    |   4 +-
 packages/ui/stories/session-list-panel.stories.tsx |  18 +-
 packages/ui/stories/session-rail-harness.tsx       |  78 ++++
 scripts/check-app-shell-hooks.mjs                  | 447 +++++++++++++++++++++
 scripts/check-app-shell-hooks.test.mjs             | 255 ++++++++++++
 scripts/perf/README.md                             |  88 ++++
 scripts/perf/cdp-client.mjs                        | 155 +++++++
 scripts/perf/react-commit-probe.js                 | 191 +++++++++
 scripts/perf/session-switch-busy-js.mjs            | 102 +++++
 scripts/perf/session-switch-commits.mjs            |  96 +++++
 57 files changed, 3013 insertions(+), 797 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2ae34ebf1..eb1894a3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,15 @@ jobs:
       - name: Test script entrypoint contracts
         run: node --test scripts/script-entrypoints.test.mjs
 
+      # Install-free like its neighbours: the gate reads one source file and
+      # compares it to a hand-edited inventory, so a hook that silently widens
+      # its scope to the whole tree fails here rather than in a profile (#4109).
+      - name: Check the hooks scoped to the whole shell
+        run: npm run check:app-shell-hooks
+
+      - name: Test the app-shell hook gate
+        run: node --test --test-concurrency=1 scripts/check-app-shell-hooks.test.mjs
+
       - name: Verify ASF npm preflight policy
         run: npm run check:asf-npm
 
diff --git a/apps/desktop/e2e/session-rail-render-contract.spec.ts b/apps/desktop/e2e/session-rail-render-contract.spec.ts
index 7a4dbb34b..722209475 100644
--- a/apps/desktop/e2e/session-rail-render-contract.spec.ts
+++ b/apps/desktop/e2e/session-rail-render-contract.spec.ts
@@ -49,6 +49,12 @@ import {
  * missing middle of the fix's own claim — identity is fixed so `memo` holds, and
  * `memo` holding means untouched rows produce no DOM work at all.
  *
+ * The last two assertions are the timing half, folded in from #4109's own rail
+ * budget rather than kept as a second spec: how much was rewritten does not say
+ * whether the user saw it. The selection must pass through exactly one row on
+ * its way to the clicked one, and the status badges — which belong to sessions
+ * whose state did not change — must not be torn down and rebuilt underneath it.
+ *
  * `styleWrites > 0` is the counter's own liveness check. Every write counted
  * here comes from an Astryx ref callback that is not wrapped in `useCallback`;
  * if that upstream detail is ever memoised, both the healthy and the regressed
@@ -64,6 +70,18 @@ interface RailCounters {
   styleWrites: number;
   rowIds: string[];
   rowRemounts: number;
+  /**
+   * The selected row after each batch of records, appended only when it moved.
+   * Counting `aria-current` writes instead would read a re-render that rewrites
+   * the same selection as the selection moving; this reads what the user sees.
+   */
+  selectedRowIds: (string | null)[];
+  /**
+   * Status badges added or removed. They belong to sessions whose state did not
+   * change, so rebuilding them is the visible half of a rail re-render: the
+   * badges flash. `rowRemounts` does not cover it — the row survives.
+   */
+  statusNodeChanges: number;
   /** Drained by every quiet poll, so it reports only the latest interval. */
   delta: number;
 }
@@ -113,7 +131,20 @@ test('switching sessions does not rewrite the whole Session rail', async ({
   await expect(target).toBeVisible();
 
   await page.evaluate(() => {
-    const counters = { styleWrites: 0, rowIds: [] as string[], rowRemounts: 0, delta: 0 };
+    const selectedRowId = (): string | null =>
+      document
+        .querySelector('.maka-session-row button.astryx-side-nav-item.selected')
+        ?.closest('.maka-session-row')
+        ?.getAttribute('data-session-id') ?? null;
+
+    const counters = {
+      styleWrites: 0,
+      rowIds: [] as string[],
+      rowRemounts: 0,
+      selectedRowIds: [selectedRowId()] as (string | null)[],
+      statusNodeChanges: 0,
+      delta: 0,
+    };
     (window as unknown as { __railCounters: typeof counters }).__railCounters = counters;
     const observer = new MutationObserver((records) => {
       for (const record of records) {
@@ -127,6 +158,16 @@ test('switching sessions does not rewrite the whole Session rail', async ({
             // cheaper than a rail re-render. Count the remounts directly.
             if (element.classList?.contains('maka-session-row')) counters.rowRemounts += 1;
           }
+          for (const node of [...record.addedNodes, ...record.removedNodes]) {
+            const element = node as Element;
+            if (element.nodeType !== 1) continue;
+            if (
+              element.matches?.('[data-session-status]') ||
+              element.querySelector?.('[data-session-status]')
+            ) {
+              counters.statusNodeChanges += 1;
+            }
+          }
           continue;
         }
         const row = (record.target as Element).closest?.('.maka-session-row');
@@ -136,6 +177,8 @@ test('switching sessions does not rewrite the whole Session rail', async ({
         const rowId = row.getAttribute('data-session-id');
         if (rowId && !counters.rowIds.includes(rowId)) counters.rowIds.push(rowId);
       }
+      const selected = selectedRowId();
+      if (selected !== counters.selectedRowIds.at(-1)) counters.selectedRowIds.push(selected);
     });
     observer.observe(document.body, {
       subtree: true,
@@ -153,9 +196,16 @@ test('switching sessions does not rewrite the whole Session rail', async ({
     counters.styleWrites = 0;
     counters.rowIds = [];
     counters.rowRemounts = 0;
+    counters.selectedRowIds = counters.selectedRowIds.slice(-1);
+    counters.statusNodeChanges = 0;
     counters.delta = 0;
   });
 
+  const targetId = await page
+    .locator('.maka-session-row', { hasText: 'Rail row 3' })
+    .getAttribute('data-session-id');
+  expect(targetId).toBeTruthy();
+
   await target.click();
   await expect(selected).toHaveText(/Rail row 3/);
   // Let the post-switch commit cascade finish before reading the counters.
@@ -164,8 +214,9 @@ test('switching sessions does not rewrite the whole Session rail', async ({
   const counted = await page.evaluate(() => {
     const scope = window as unknown as RailWindow & { __railObserver: MutationObserver };
     scope.__railObserver.disconnect();
-    const { styleWrites, rowIds, rowRemounts } = scope.__railCounters;
-    return { styleWrites, rowIds, rowRemounts };
+    const { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges } =
+      scope.__railCounters;
+    return { styleWrites, rowIds, rowRemounts, selectedRowIds, statusNodeChanges };
   });
 
   expect(
@@ -177,4 +228,13 @@ test('switching sessions does not rewrite the whole Session rail', async ({
   expect(counted.styleWrites, 'rail inline-style writes for one session switch').toBeLessThanOrEqual(
     RAIL_STYLE_WRITE_BUDGET,
   );
+
+  // The timing half. A budget says how much was rewritten, not whether the user
+  // saw it happen: a switch that lands the selection on a third row and takes it
+  // back stays well inside every count above (#4109).
+  expect(
+    counted.selectedRowIds.slice(1),
+    'rows the selection passed through during one switch',
+  ).toEqual([targetId]);
+  expect(counted.statusNodeChanges, 'status badges rebuilt by one session switch').toBe(0);
 });
diff --git a/apps/desktop/src/main/__tests__/session-list-layout.test.ts b/apps/desktop/src/main/__tests__/session-list-layout.test.ts
index c15c765bd..8726f5bb7 100644
--- a/apps/desktop/src/main/__tests__/session-list-layout.test.ts
+++ b/apps/desktop/src/main/__tests__/session-list-layout.test.ts
@@ -18,13 +18,15 @@
  */
 
 import assert from 'node:assert/strict';
-import { afterEach, describe, it } from 'node:test';
+import { afterEach, describe, it, mock } from 'node:test';
 import {
+  createSessionRailLayoutStore,
   readSessionListViewMode,
   writeSessionListViewMode,
 } from '../../renderer/features/session-navigation/testing.js';
 
 const VIEW_MODE_KEY = 'maka-chat-list-view-mode-v1';
+const WIDTH_KEY = 'maka-chat-list-width-v1';
 
 function installMemoryLocalStorage(initial: Record<string, string> = {}) {
   const store = new Map<string, string>(Object.entries(initial));
@@ -96,3 +98,43 @@ describe('session list view mode persistence', () => {
     }
   });
 });
+
+describe('session rail width persistence', () => {
+  const cleanups: Array<() => void> = [];
+  afterEach(() => {
+    mock.timers.reset();
+    while (cleanups.length > 0) cleanups.pop()?.();
+  });
+
+  it('persists a width the user dragged to', () => {
+    const memory = installMemoryLocalStorage();
+    cleanups.push(memory.restore);
+    mock.timers.enable({ apis: ['setTimeout'] });
+    const store = createSessionRailLayoutStore();
+
+    store.setWidth(400);
+    mock.timers.tick(200);
+
+    assert.equal(store.getState().width, 400);
+    assert.equal(memory.store.get(WIDTH_KEY), '400');
+  });
+
+  // Astryx reports a collapse as `onSizeChange(0)`. Clamping that to the minimum
+  // and storing it loses the width the user chose: collapse, reload, expand, and
+  // the rail comes back at 180 instead of 400.
+  it("ignores the collapse sentinel instead of storing it as the user's width", () => {
+    const memory = installMemoryLocalStorage();
+    cleanups.push(memory.restore);
+    mock.timers.enable({ apis: ['setTimeout'] });
+    const store = createSessionRailLayoutStore();
+    store.setWidth(400);
+    mock.timers.tick(200);
+
+    store.setCollapsed(true);
+    store.setWidth(0);
+    mock.timers.tick(200);
+
+    assert.equal(store.getState().width, 400);
+    assert.equal(memory.store.get(WIDTH_KEY), '400');
+  });
+});
diff --git a/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts b/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts
index d2fb1b3db..4d7971590 100644
--- a/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts
+++ b/apps/desktop/src/main/__tests__/session-navigation-boundary.test.ts
@@ -100,7 +100,6 @@ describe('Session Navigation feature boundary', () => {
       'utf8',
     );
     for (const forbidden of [
-      '<SessionListPanel',
       'deriveSessionRail(',
       'deriveSessionRevisionNavigation(',
       'readSessionListViewMode(',
@@ -113,13 +112,11 @@ describe('Session Navigation feature boundary', () => {
     ]) {
       assert.equal(appShell.includes(forbidden), false, forbidden);
     }
-    assert.equal(
-      appShell.includes('const sessionNavigation = useSessionNavigationController({'),
-      true,
-    );
-    assert.equal(
-      appShell.includes('<SessionNavigationHost'),
-      true,
-    );
+    // The rail's state lives under its own provider, so AppShell holds no
+    // controller at all: it renders the provider and reads back the projections
+    // it needs for the palette, the titlebar, and the frame's width (#4109).
+    assert.equal(appShell.includes('useSessionNavigationController'), false);
+    assert.equal(appShell.includes('} = useSessionNavigationReads({'), true);
+    assert.equal(appShell.includes('<SessionNavigationProvider'), true);
   });
 });
diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
index 9cbb6dfab..77ae05455 100644
--- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
@@ -25,9 +25,14 @@ import { LocaleProvider } from '@maka/ui';
 import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
 import {
   createFakeSessionNavigationServices,
+  createSessionOpenCommand,
+  deriveSessionRail,
+  sessionMatchesRail,
   SessionNavigationServicesProvider,
   useSessionNavigationController,
+  useSessionNavigationReads,
   type SessionNavigationController,
+  type SessionNavigationPorts,
   type SessionNavigationSession,
   type UseSessionNavigationControllerInput,
 } from '../../renderer/features/session-navigation/testing.js';
@@ -63,6 +68,10 @@ const project: ProjectRecord = {
   available: true,
 };
 
+const hiddenSessionIds = new Set(['hidden']);
+
+const fakeServices = createFakeSessionNavigationServices();
+
 let latestController: SessionNavigationController | undefined;
 
 function ControllerProbe(props: UseSessionNavigationControllerInput) {
@@ -79,7 +88,7 @@ function renderController(
       locale: 'en',
       children: createElement(
         SessionNavigationServicesProvider,
-        { services: createFakeSessionNavigationServices() },
+        { services: fakeServices },
         createElement(ControllerProbe, input),
       ),
     }),
@@ -91,24 +100,19 @@ function controller(): SessionNavigationController {
   return latestController;
 }
 
-function input(
+function ports(
   sessions: SessionNavigationSession[],
   activeSessionId: string | undefined,
   calls: string[] = [],
-  targets: unknown[] = [],
-): UseSessionNavigationControllerInput {
+): SessionNavigationPorts {
   return {
-    sessions,
-    activeSessionId,
-    hiddenSessionIds: new Set(['hidden']),
-    projects: [project],
+    activeIdRef: { current: activeSessionId },
+    sessionsRef: { current: sessions },
+    pendingSessionRowActionsRef: { current: new Set<string>() },
     activateSession: (sessionId) => calls.push(`activate:${sessionId ?? 'none'}`),
     clearActiveMessages: () => calls.push('clear-messages'),
     clearSessionRendererState: (sessionId) => calls.push(`clear:${sessionId}`),
-    exitWorkHub: () => calls.push('exit-workhub'),
     refreshSessions: async () => sessions,
-    selectSessionSurface: () => calls.push('select-sessions'),
-    setSearchTarget: (target) => targets.push(target),
     toastApi: {
       success: () => undefined,
       error: () => undefined,
@@ -117,72 +121,135 @@ function input(
   };
 }
 
+function input(
+  sessions: SessionNavigationSession[],
+  activeSessionId: string | undefined,
+  calls: string[] = [],
+): UseSessionNavigationControllerInput {
+  return {
+    rail: deriveSessionRail(
+      sessions,
+      activeSessionId,
+      (candidate) => !hiddenSessionIds.has(candidate.id) && sessionMatchesRail(candidate),
+    ),
+    projects: [project],
+    ports: ports(sessions, activeSessionId, calls),
+  };
+}
+
+const linkedCatalog = [
+  session('root', { projectId: 'project', cwd: '/repo' }),
+  session('child', {
+    parentSessionId: 'root',
+    subagentParent: {
+      kind: 'subagent',
+      parentSessionId: 'root',
+      spawnedBy: {
+        parentRunId: 'run',
+        parentTurnId: 'turn',
+        toolCallId: 'tool',
+      },
+      lifecycle: 'foreground',
+    },
+  }),
+  session('remote', {
+    profileId: 'remote-profile',
+    profileName: 'Remote Mac',
+    profileKind: 'remote',
+  }),
+  session('environment', {
+    profileId: 'wsl-ubuntu',
+    profileName: 'Ubuntu',
+    profileKind: 'environment',
+  }),
+  session('archived', { isArchived: true }),
+  session('hidden'),
+];
+
 afterEach(() => {
   latestController = undefined;
   cleanupFakeDom();
 });
 
 describe('useSessionNavigationController', () => {
+  it('groups the rail by Project and Runtime Host, and names Host-workspace rows', async () => {
+    const { root } = installReactRenderer();
+    await act(async () => renderController(root, input(linkedCatalog, 'child')));
+
+    assert.deepEqual(
+      controller().selectors.groups.map(({ id }) => id),
+      ['project:project', 'runtime-host:remote-profile', 'runtime-host:wsl-ubuntu'],
+    );
+    assert.equal(controller().selectors.sessionMeta(linkedCatalog[2]!), 'Remote Mac');
+    assert.equal(controller().selectors.sessionMeta(linkedCatalog[3]!), 'Ubuntu');
+  });
+
+  it('builds row mutations once, so the rail below it is not rebuilt per render', async () => {
+    const { root } = installReactRenderer();
+    const stableInput = input(linkedCatalog, 'child');
+    await act(async () => renderController(root, stableInput));
+    const first = controller().commands;
+    await act(async () => renderController(root, { ...stableInput }));
+
+    assert.equal(controller().commands, first);
+  });
+});
+
+describe('useSessionNavigationReads', () => {
+  let latestReads: ReturnType<typeof useSessionNavigationReads> | undefined;
+
+  function ReadsProbe(props: Parameters<typeof useSessionNavigationReads>[0]) {
+    latestReads = useSessionNavigationReads(props);
+    return null;
+  }
+
+  afterEach(() => {
+    latestReads = undefined;
+  });
+
   it('projects linked, archived, hidden, Project, and Runtime Host Sessions once', async () => {
     const { root } = installReactRenderer();
-    const sessions = [
-      session('root', { projectId: 'project', cwd: '/repo' }),
-      session('child', {
-        parentSessionId: 'root',
-        subagentParent: {
-          kind: 'subagent',
-          parentSessionId: 'root',
-          spawnedBy: {
-            parentRunId: 'run',
-            parentTurnId: 'turn',
-            toolCallId: 'tool',
-          },
-          lifecycle: 'foreground',
-        },
-      }),
-      session('remote', {
-        profileId: 'remote-profile',
-        profileName: 'Remote Mac',
-        profileKind: 'remote',
-      }),
-      session('environment', {
-        profileId: 'wsl-ubuntu',
-        profileName: 'Ubuntu',
-        profileKind: 'environment',
-      }),
-      session('archived', { isArchived: true }),
-      session('hidden'),
-    ];
-
-    await act(async () => renderController(root, input(sessions, 'child')));
+    await act(async () =>
+      root.render(
+        createElement(LocaleProvider, {
+          locale: 'en',
+          children: createElement(ReadsProbe, {
+            sessions: linkedCatalog,
+            activeSessionId: 'child',
+            activeSession: linkedCatalog[1],
+            hiddenSessionIds,
+          }),
+        }),
+      ),
+    );
 
+    assert.ok(latestReads);
     assert.deepEqual(
-      controller().selectors.visibleSessions.map(({ id }) => id),
+      latestReads.rail.sessions.map(({ id }) => id),
       ['root', 'remote', 'environment'],
     );
-    assert.equal(controller().selectors.activeRowId, 'root');
-    assert.equal(controller().selectors.activeParentSession?.id, 'root');
-    assert.deepEqual(controller().selectors.branchBanner, {
+    assert.equal(latestReads.rail.activeRowId, 'root');
+    assert.equal(latestReads.rail.activeParentSession?.id, 'root');
+    assert.deepEqual(latestReads.branchBanner, {
       parentSessionId: 'root',
       parentSessionName: 'root',
     });
-    assert.deepEqual(
-      controller().selectors.groups.map(({ id }) => id),
-      ['project:project', 'runtime-host:remote-profile', 'runtime-host:wsl-ubuntu'],
-    );
-    assert.equal(controller().selectors.sessionMeta(sessions[2]!), 'Remote Mac');
-    assert.equal(controller().selectors.sessionMeta(sessions[3]!), 'Ubuntu');
   });
+});
 
-  it('owns Session jumps and preserves turn-target clearing semantics', async () => {
-    const { root } = installReactRenderer();
+describe('createSessionOpenCommand', () => {
+  it('orders the jump and preserves turn-target clearing semantics', () => {
     const calls: string[] = [];
     const targets: unknown[] = [];
-    const sessions = [session('a')];
-    await act(async () => renderController(root, input(sessions, undefined, calls, targets)));
+    const openSession = createSessionOpenCommand({
+      activateSession: (sessionId) => calls.push(`activate:${sessionId}`),
+      exitWorkHub: () => calls.push('exit-workhub'),
+      selectSessionSurface: () => calls.push('select-sessions'),
+      setSearchTarget: (target) => targets.push(target),
+    });
 
-    await act(async () => controller().commands.openSession('a', 'turn-2', 9));
-    await act(async () => controller().commands.openSession('a'));
+    openSession('a', 'turn-2', 9);
+    openSession('a');
 
     assert.deepEqual(calls, [
       'exit-workhub',
diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts
index 98dcfb860..3865055dd 100644
--- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts
@@ -163,14 +163,20 @@ describe('Workbar feature boundary', () => {
       ),
       true,
     );
+    // The catalog reaches the panel through ComposerMentionsProvider rather
+    // than through props (#4109), so what this guards is the context read, not
+    // the prop names. Both facets still have to arrive together: a panel that
+    // showed the skills without their loading verdict would advertise a
+    // catalog it cannot honour.
+    assert.equal(quotePanel.includes('useComposerMentionsContext()'), true);
     assert.equal(
       quotePanel.includes(
-        'mentionSkillsUnavailable={props.mentionSkillsUnavailable}',
+        'mentionSkillsUnavailable={mentions?.mentionSkillsUnavailable}',
       ),
       true,
     );
     assert.equal(
-      quotePanel.includes('mentionSkillsLoading={props.mentionSkillsLoading}'),
+      quotePanel.includes('mentionSkillsLoading={mentions?.mentionSkillsLoading}'),
       true,
     );
   });
diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts
index bb42b79a1..aa7812782 100644
--- a/apps/desktop/src/renderer/app-shell-command-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-command-actions.ts
@@ -74,7 +74,7 @@ export interface AppShellCommandListOptions {
   newTaskProfileId: string | undefined;
   settingsOpen: boolean;
   settingsProfileId: string | undefined;
-  sessions: SessionSummary[];
+  sessions: readonly SessionSummary[];
   themePref: ThemePreference;
   visibleSessions: SessionSummary[];
   captureComposerImportOwner: () => ComposerImportOwner;
diff --git a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts
index fa88f59c9..b37fc542e 100644
--- a/apps/desktop/src/renderer/app-shell-e2e-fixture.ts
+++ b/apps/desktop/src/renderer/app-shell-e2e-fixture.ts
@@ -34,7 +34,7 @@ export function createAppShellE2eFixtureActions(options: {
   setActiveId: (sessionId: string | undefined) => void;
   setNavSelection: Dispatch<SetStateAction<NavSelection>>;
   setSearchModalOpen: Dispatch<SetStateAction<boolean>>;
-  setSessionListCollapsed: Dispatch<SetStateAction<boolean>>;
+  setSessionListCollapsed(collapsed: boolean): void;
   workbar: {
     rightCollapsed: boolean;
     toggleRight(): void;
diff --git a/apps/desktop/src/renderer/app-shell-session-ui-state.ts b/apps/desktop/src/renderer/app-shell-session-ui-state.ts
index ae0115175..aa283d9f9 100644
--- a/apps/desktop/src/renderer/app-shell-session-ui-state.ts
+++ b/apps/desktop/src/renderer/app-shell-session-ui-state.ts
@@ -21,6 +21,7 @@ import { useRef } from 'react';
 import type { MessageQueueEntryProjection } from '@maka/core/events';
 import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health';
 import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui';
+import { createObservableState } from './observable-state.js';
 import type { ShellRunUpdatesBySession } from './shell-run-update-state.js';
 
 type StateUpdater<T> = (updater: (current: T) => T) => void;
@@ -143,38 +144,27 @@ export function clearAppShellTurnTransientForSession(
 export function createAppShellSessionUiStateController(
   initialState: AppShellSessionUiState = createInitialAppShellSessionUiState(),
 ) {
-  let currentState = initialState;
-  const liveTurnBySessionRef = { current: currentState.liveTurnBySession };
+  const state = createObservableState(initialState);
+  const liveTurnBySessionRef = { current: initialState.liveTurnBySession };
   // Written by the event-health probes and read back by them alone. Kept off
-  // `currentState` so a probe never notifies a subscriber.
+  // the observed state so a probe never notifies a subscriber.
   const sessionEventHealthBySessionRef: { current: Record<string, SessionEventStreamSnapshot> } = {
     current: {},
   };
-  const listeners = new Set<() => void>();
 
+  // The ref mirrors whatever is about to become current, so it is already
+  // correct when the synchronous notification reaches a listener that reads it.
   function replaceState(next: AppShellSessionUiState): void {
-    if (next === currentState) return;
-    currentState = next;
     liveTurnBySessionRef.current = next.liveTurnBySession;
-    // Synchronous, immediately after the swap. Never schedule this: the
-    // terminal-turn handoff reads the state it announces (#1985).
-    for (const listener of [...listeners]) listener();
-  }
-
-  /** Subscribe to state replacements. Stable identity, for `useSyncExternalStore`. */
-  function subscribe(listener: () => void): () => void {
-    listeners.add(listener);
-    return () => {
-      listeners.delete(listener);
-    };
+    state.replaceState(next);
   }
 
   function updateMap<K extends AppShellSessionUiStateMapKey>(
     key: K,
     updater: (current: AppShellSessionUiState[K]) => AppShellSessionUiState[K],
   ): void {
-    const nextMap = updater(currentState[key]);
-    const latestState = currentState;
+    const latestState = state.getState();
+    const nextMap = updater(latestState[key]);
     if (nextMap === latestState[key]) return;
     replaceState({ ...latestState, [key]: nextMap });
   }
@@ -198,7 +188,7 @@ export function createAppShellSessionUiStateController(
   function createPendingClaim(key: BooleanMapKey): SessionPendingClaim {
     return {
       claim(claimKey: string): boolean {
-        if (currentState[key][claimKey] === true) return false;
+        if (state.getState()[key][claimKey] === true) return false;
         updateMap(key, (current) => ({ ...current, [claimKey]: true }));
         return true;
       },
@@ -209,8 +199,8 @@ export function createAppShellSessionUiStateController(
   }
 
   return {
-    getState: () => currentState,
-    subscribe,
+    getState: state.getState,
+    subscribe: state.subscribe,
     liveTurnBySessionRef,
     sessionEventHealthBySessionRef,
     setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'),
@@ -244,14 +234,15 @@ export function createAppShellSessionUiStateController(
         sessionEventHealthBySessionRef.current,
         sessionId,
       );
-      replaceState(clearAppShellSessionUiStateForSession(currentState, sessionId));
+      replaceState(clearAppShellSessionUiStateForSession(state.getState(), sessionId));
     },
     clearTurnTransientStateIfCurrent: (
       sessionId: string,
       expected: LiveTurnProjection | undefined,
     ) => {
-      if (currentState.liveTurnBySession[sessionId] !== expected) return;
-      replaceState(clearAppShellTurnTransientForSession(currentState, sessionId));
+      const current = state.getState();
+      if (current.liveTurnBySession[sessionId] !== expected) return;
+      replaceState(clearAppShellTurnTransientForSession(current, sessionId));
     },
   };
 }
@@ -261,7 +252,7 @@ export type AppShellSessionUiStateController = ReturnType<typeof createAppShellS
 /**
  * Owns the controller for the component's lifetime. Deliberately does NOT
  * subscribe: readers select what they need through
- * `useAppShellSessionUiSelector`, so no single component re-renders for every
+ * `useExternalStoreSelector`, so no single component re-renders for every
  * write to the store (#1985).
  *
  * Returns the controller itself rather than a bag of its members. The bag had
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index dab53a0d6..cfac7f8a4 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -58,6 +58,8 @@ import {
   type ToastDiagnosticTarget,
   type ToastErrorAction,
   type NavSelection,
+  type ProjectRowActions,
+  SessionListPanel,
   TitlebarSessionIdentity,
   type TurnFooterActionMeta,
   useToast,
@@ -91,8 +93,12 @@ import {
 import { GoalHost, useGoalController } from './features/goals';
 import { ModuleHubHost, useModuleHubController } from './features/module-hub';
 import {
-  SessionNavigationHost,
-  useSessionNavigationController,
+  SessionNavigationProvider,
+  createSessionOpenCommand,
+  sessionRailLayoutStore,
+  useSessionNavigationReads,
+  type SessionNavigationPorts,
+  type SessionNavigationRowActions,
 } from './features/session-navigation';
 import {
   TaskEntryHost,
@@ -183,6 +189,7 @@ import {
 import { createAppShellSessionStartActions } from './app-shell-session-start-actions';
 import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions';
 import { createAppShellStopAction } from './app-shell-stop-action';
+import { useExternalStoreSelector } from './use-external-store-selector';
 import { useStableActions } from './use-stable-actions';
 import {
   useActiveSessionEvents,
@@ -204,7 +211,7 @@ import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'
 import { useTurnActionRegistry } from './use-turn-action-registry';
 import { useComposerAttachments } from './use-composer-attachments';
 import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes';
-import { useComposerMentions } from './use-composer-mentions';
+import { ComposerMentionsProvider, type ComposerMentionsSurface } from './composer-mentions';
 import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace';
 import { useShellMemoryPill } from './use-shell-memory-pill';
 import { useShellConnections } from './use-shell-connections';
@@ -311,6 +318,17 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {
   );
 }
 
+/**
+ * The Session rail, as one element built once.
+ *
+ * AppShell re-renders about fourteen times per session switch. Written inline
+ * in the JSX below, each of those rebuilt this element and re-rendered the
+ * rail's ~1,000 fibers with it; hoisted here, React sees the same element and
+ * skips the subtree, and what reaches the rail is the two rail contexts alone.
+ * The panel takes no props for exactly this reason (#4109).
+ */
+const SESSION_RAIL = <SessionListPanel />;
+
 function AppShellContent({
   initialOnboardingSnapshot = null,
   uiLocale,
@@ -371,6 +389,10 @@ function AppShellContent({
   const taskEntry = useTaskEntryController({
     reportError: reportTaskEntryError,
   });
+  // Named on its own because the rail depends on it: `taskEntry.commands` is a
+  // fresh object every render, so depending on the bag rather than the command
+  // would rebuild the rail's Project rows on every AppShell commit (#4109).
+  const { selectLocalProject } = taskEntry.commands;
   const currentNewTaskDraftKey = taskEntry.selectors.draftKey;
   // Staged files and quotes do NOT take the target-scoped key: they belong to
   // the composer the user is looking at, and an in-flight send needs an owner
@@ -886,6 +908,9 @@ function AppShellContent({
   const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) =>
     `${sessionId}:${turnId}:${actionId}`;
 
+  // A hoisted declaration on purpose: `dropDisplayEvents` is destructured
+  // hundreds of lines below, and the rail does not need this identity held
+  // still — the rail's controller reads it through `portsRef`.
   function clearSessionRendererState(sessionId: string): void {
     dropDisplayEvents(sessionId);
     // `clearOwnedSessionState` ends in `clearSessionUiState`, which drops this
@@ -948,6 +973,8 @@ function AppShellContent({
     },
   });
 
+  // Stable: the rail's row actions are built from it, and it only reaches
+  // registries and refs that are themselves stable (#4109).
   /**
    * Enter or leave Plan for one Session — the only path that writes
    * `collaborationMode`, and it writes nothing else.
@@ -1515,28 +1542,70 @@ function AppShellContent({
     showModelSetupToast,
     toastApi,
   });
+  const openNewTaskSurface = useCallback(() => {
+    startNewSession();
+    // Only Plan resets: a new task starts out of Plan, in whatever
+    // orchestration the last one was set to.
+    setNewChatPlanModeActive(false);
+    setNavSelection({ section: 'sessions' });
+    setSearchScrollTarget(null);
+    // New-task affordances reset to the empty-state composer; move focus
+    // there so the user can start typing immediately.
+    window.requestAnimationFrame(() => composerRef.current?.focus());
+  }, [setNavSelection, setSearchScrollTarget, startNewSession]);
+
+  const createSession = useCallback(async () => {
+    openNewTaskSurface();
+  }, [openNewTaskSurface]);
+
+  // Stable, because the rail's Project rows carry it: a fresh identity here
+  // rebuilt the whole list on every AppShell commit (#4109).
+  const createSessionInProject = useCallback(
+    async (projectId: string) => {
+      if (!selectLocalProject(projectId)) return;
+      openNewTaskSurface();
+    },
+    [openNewTaskSurface, selectLocalProject],
+  );
+
   // Sidebar Project groups are Local. Their catalog mutations remain on the
   // default-scoped bridge until Settings receives its own Host selector.
-  const projectRowActions: ComponentProps<typeof SessionNavigationHost>['projectActions'] =
-    projectCapabilities.setLocalDefault
-      ? {
-          onNew: createSessionInProject,
-          onRename: renameProject,
-          onArchive: archiveProject,
-          onRestore: restoreProject,
-          ...(projectCapabilities.chooseClientDirectory
-            ? {
-                onRelink: (projectId: string) =>
-                  relinkProject(projectId).then(() => undefined),
-              }
-            : {}),
-        }
-      : undefined;
+  //
+  // Memoized because the rail reads it: rebuilt per render, this one object
+  // would put the whole list back on every AppShell commit (#4109).
+  const projectRowActions = useMemo<ProjectRowActions | undefined>(
+    () =>
+      projectCapabilities.setLocalDefault
+        ? {
+            onNew: createSessionInProject,
+            onRename: renameProject,
+            onArchive: archiveProject,
+            onRestore: restoreProject,
+            ...(projectCapabilities.chooseClientDirectory
+              ? {
+                  onRelink: (projectId: string) =>
+                    relinkProject(projectId).then(() => undefined),
+                }
+              : {}),
+          }
+        : undefined,
+    [
+      archiveProject,
+      createSessionInProject,
+      projectCapabilities.chooseClientDirectory,
+      projectCapabilities.setLocalDefault,
+      relinkProject,
+      renameProject,
+      restoreProject,
+    ],
+  );
 
   // Composer mention popups: `/` uses Runtime's session/project-aware,
   // host-compatible projection; `@` uses workspace file search. Keep the
-  // resolved project path as a refresh key for new-chat project changes.
-  const { mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles } = useComposerMentions({
+  // resolved project path as a refresh key for new-chat project changes. Only
+  // the SURFACE is named here — the projection itself is owned by
+  // `ComposerMentionsProvider` below, so its reloads do not re-render the shell.
+  const composerMentionsSurface: ComposerMentionsSurface = {
     skillCatalogRevision: moduleHub.selectors.skillCatalogRevision,
     sessionId: activeId,
     projectPath: activeId ? projectInfo?.projectPath : taskEntry.selectors.projectPath,
@@ -1546,7 +1615,7 @@ function AppShellContent({
     // Refresh only; Desktop Main re-reads the authoritative default before
     // constructing the Runtime Host preview target.
     newSessionPermissionMode: newTaskPermissionMode,
-  });
+  };
 
   const hasModalOpen = helpOpen || paletteOpen || searchModalOpen;
   const shellObscured = hasModalOpen || settingsOpen;
@@ -1579,10 +1648,6 @@ function AppShellContent({
     authoritativeSessionIds: authoritativeSessionIds ?? undefined,
     shellObscured,
     modelChoices: chatModelChoices,
-    mentionSkills,
-    mentionSkillsUnavailable,
-    mentionSkillsLoading,
-    searchMentionFiles,
     reportError: reportWorkbarError,
   });
 
@@ -1592,48 +1657,70 @@ function AppShellContent({
     [setNavSelection],
   );
   const clearActiveMessages = useCallback(() => setMessages([]), [setMessages]);
-  const sessionNavigation = useSessionNavigationController({
-    sessions,
-    activeSessionId: activeId,
-    hiddenSessionIds: workbar.selectors.hiddenSessionIds,
-    projects: localProjects,
+  const openSession = useMemo(
+    () =>
+      createSessionOpenCommand({
+        activateSession: setActiveId,
+        exitWorkHub,
+        selectSessionSurface,
+        setSearchTarget: setSearchScrollTarget,
+      }),
+    [exitWorkHub, selectSessionSurface, setActiveId, setSearchScrollTarget],
+  );
+  useLayoutEffect(() => {
+    openSessionInChatRef.current = openSession;
+  }, [openSession]);
+  const pendingSessionRowActionsRef = useRef(new Set<string>());
+  const sessionNavigationCommandsRef = useRef<SessionNavigationRowActions | null>(null);
+  // Built inline: the rail reads these through a ref published on commit, so
+  // their identity carries no information and this object never has to be
+  // held still by hand (#4109).
+  const sessionNavigationPorts: SessionNavigationPorts = {
+    activeIdRef,
+    sessionsRef,
+    pendingSessionRowActionsRef,
     activateSession: setActiveId,
     clearActiveMessages,
     clearSessionRendererState,
-    exitWorkHub,
     refreshSessions,
-    selectSessionSurface,
-    setSearchTarget: setSearchScrollTarget,
     toastApi,
+  };
+  const {
+    rail: sessionRail,
+    branchBanner,
+    revisionNavigation,
+    layout: railLayout,
+  } = useSessionNavigationReads({
+    sessions,
+    activeSessionId: activeId,
+    activeSession,
+    hiddenSessionIds: workbar.selectors.hiddenSessionIds,
   });
-  useLayoutEffect(() => {
-    openSessionInChatRef.current = sessionNavigation.commands.openSession;
-  }, [sessionNavigation.commands.openSession]);
-  const visibleSessions = sessionNavigation.selectors.visibleSessions;
-  const sessionListCollapsed = sessionNavigation.layout.collapsed;
-  const sessionListWidth = sessionNavigation.layout.width;
-  const sessionSideNavHandleRef = sessionNavigation.layout.collapseHandleRef;
+  const visibleSessions = sessionRail.sessions;
+  const sessionListCollapsed = railLayout.collapsed;
+  const sessionListWidth = railLayout.width;
+  const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef;
   const titlebarParentSession = useMemo(() => {
-    const parent = sessionNavigation.selectors.activeParentSession;
+    const parent = sessionRail.activeParentSession;
     if (!parent) return undefined;
     const parentId = parent.id;
     return {
       name: parent.name,
       onOpen: () => openSessionInChatRef.current(parentId),
     };
-  }, [sessionNavigation.selectors.activeParentSession]);
+  }, [sessionRail.activeParentSession]);
   const archivedTasksBridge = useMemo<ArchivedTasksBridge>(
     () => ({
       sessions,
       projects: localProjects,
       onRestore: (sessionId) =>
-        void sessionNavigation.commands.unarchiveSession(sessionId),
+        void sessionNavigationCommandsRef.current?.unarchiveSession(sessionId),
       onDelete: (sessionId) =>
-        void sessionNavigation.commands.deleteSession(sessionId),
+        void sessionNavigationCommandsRef.current?.deleteSession(sessionId),
       onPurge: (sessionIds) =>
-        sessionNavigation.commands.purgeSessions(sessionIds),
+        sessionNavigationCommandsRef.current!.purgeSessions(sessionIds),
     }),
-    [sessions, localProjects, sessionNavigation.commands],
+    [sessions, localProjects],
   );
 
   const firstSendObservationWaitersRef = useRef(
@@ -1684,7 +1771,7 @@ function AppShellContent({
     setActiveId,
     setNavSelection,
     setSearchModalOpen,
-    setSessionListCollapsed: sessionNavigation.layout.setCollapsed,
+    setSessionListCollapsed: sessionRailLayoutStore.setCollapsed,
     workbar: {
       rightCollapsed: workbar.selectors.rightCollapsed,
       toggleRight: workbar.commands.toggleRight,
@@ -2389,27 +2476,6 @@ function AppShellContent({
     bootstrapSelectionLease.release();
   }
 
-  function openNewTaskSurface() {
-    startNewSession();
-    // Only Plan resets: a new task starts out of Plan, in whatever
-    // orchestration the last one was set to.
-    setNewChatPlanModeActive(false);
-    setNavSelection({ section: 'sessions' });
-    setSearchScrollTarget(null);
-    // New-task affordances reset to the empty-state composer; move focus
-    // there so the user can start typing immediately.
-    window.requestAnimationFrame(() => composerRef.current?.focus());
-  }
-
-  async function createSession() {
-    openNewTaskSurface();
-  }
-
-  async function createSessionInProject(projectId: string) {
-    if (!taskEntry.commands.selectLocalProject(projectId)) return;
-    openNewTaskSurface();
-  }
-
   /**
    * PR-UI-RENDER-2 - single chokepoint for the Markdown internal-URI
    * router. Receives a typed `MakaUriDest` from the link override in
@@ -2600,6 +2666,10 @@ function AppShellContent({
         : 'im_hub';
 
   return (
+    // Wraps the whole frame rather than each composer, so one projection serves
+    // the main composer and every side-chat panel. Left un-indented on purpose:
+    // re-indenting 500 lines of JSX would bury the change that matters.
+    <ComposerMentionsProvider {...composerMentionsSurface}>
     <div
       className="appFrame agents-layout-root"
       data-agents-page
@@ -2674,7 +2744,7 @@ function AppShellContent({
                 key={activeSessionForView.id}
                 sessionName={activeSessionForView.name}
                 onRenameSession={(name) => {
-                  void sessionNavigation.commands.renameSession(activeSessionForView.id, name);
+                  void sessionNavigationCommandsRef.current?.renameSession(activeSessionForView.id, name);
                 }}
                 project={
                   titlebarProjectName
@@ -2712,14 +2782,18 @@ function AppShellContent({
         aria-hidden={shellObscured ? 'true' : undefined}
         inert={shellObscured ? true : undefined}
         sideNav={
-          <SessionNavigationHost
-            controller={sessionNavigation}
+          <SessionNavigationProvider
+            rail={sessionRail}
+            projects={localProjects}
+            streamingSessionIds={streamingSessionIds}
+            staleSessionIds={staleSessionIds}
+            ports={sessionNavigationPorts}
+            commandsRef={sessionNavigationCommandsRef}
             onExitWorkHub={exitWorkHub}
+            onSelectSession={openSession}
             workHubActive={workHubActive}
             selection={navSelection}
             scheduledTasks={moduleHub.selectors.scheduledTasks}
-            streamingSessionIds={streamingSessionIds}
-            staleSessionIds={staleSessionIds}
             moduleMemory={navigationState.moduleMemory}
             onSelect={setNavSelection}
             onOpenSettings={openSettings}
@@ -2732,7 +2806,9 @@ function AppShellContent({
               onSelect: openWorkHub,
             } : undefined}
             projectActions={projectRowActions}
-          />
+          >
+            {SESSION_RAIL}
+          </SessionNavigationProvider>
         }
       >
         <AppShellDetailPanel agentsView={agentsView}>
@@ -2847,11 +2923,7 @@ function AppShellContent({
                         }
                       : undefined
                   }
-                  mentionSkills={mentionSkills}
-                  mentionSkillsUnavailable={mentionSkillsUnavailable}
-                  mentionSkillsLoading={mentionSkillsLoading}
                   slashCommands={desktopSlashCommands}
-                  onSearchMentionFiles={searchMentionFiles}
                   pendingAttachments={pendingAttachments}
                   onRemoveAttachment={removeAttachment}
                   pendingQuotes={pendingQuotes}
@@ -3019,9 +3091,9 @@ function AppShellContent({
                   ? (target) => openSessionInChat(activeId, target.turnId, target.sequence)
                   : undefined}
                 scrollBehavior={readScrollMotionBehavior()}
-                branchBanner={sessionNavigation.selectors.branchBanner}
+                branchBanner={branchBanner}
                 onBranchBannerClick={openSessionInChat}
-                revisionNavigation={sessionNavigation.selectors.revisionNavigation}
+                revisionNavigation={revisionNavigation}
                 onRevisionNavigate={openSessionInChat}
                 onNew={createSession}
                 onPromptSuggestion={(prompt) => composerRef.current?.appendText(prompt)}
@@ -3165,5 +3237,6 @@ function AppShellContent({
         onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId}
       />
     </div>
+    </ComposerMentionsProvider>
   );
 }
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index d4a4dcff6..8b35a2a0a 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -20,6 +20,7 @@
 import { useLayoutEffect, useRef, type ComponentProps, type RefObject } from 'react';
 import { Button, Composer, SandboxBoundaryPrompt, UserQuestionPrompt, Banner } from '@maka/ui';
 import type { ComposerHandle, ComposerInteraction } from '@maka/ui';
+import { useComposerMentionsContext } from './composer-mentions.js';
 import {
   readNewTaskReloadDraft,
   readNewTaskReloadIntent,
@@ -67,7 +68,19 @@ interface BoundaryUnreadableNotice {
  * `draftKey`, and `stopPending` are derived here from the active-session state
  * so AppShell only forwards the orchestration callbacks and the session maps.
  */
-interface ChatComposerRegionProps extends Omit<ComponentProps<typeof Composer>, 'hidden' | 'draftKey' | 'stopPending'> {
+interface ChatComposerRegionProps
+  extends Omit<
+    ComponentProps<typeof Composer>,
+    | 'hidden'
+    | 'draftKey'
+    | 'stopPending'
+    // Read from ComposerMentionsProvider, so a catalog reload repaints the
+    // popups without re-rendering the shell that would otherwise pass them.
+    | 'mentionSkills'
+    | 'mentionSkillsUnavailable'
+    | 'mentionSkillsLoading'
+    | 'onSearchMentionFiles'
+  > {
   composerRef: RefObject<ComposerHandle | null>;
   active: boolean;
   onboardingComposerHidden: boolean;
@@ -102,6 +115,7 @@ export function ChatComposerRegion({
   boundaryUnreadableNotice,
   ...composerRest
 }: ChatComposerRegionProps) {
+  const mentions = useComposerMentionsContext();
   const previousNewTaskDraftKey = useRef(newTaskDraftKey);
   useLayoutEffect(() => {
     const previous = previousNewTaskDraftKey.current;
@@ -203,6 +217,10 @@ export function ChatComposerRegion({
       <Composer
         ref={composerRef}
         {...composerRest}
+        mentionSkills={mentions?.mentionSkills}
+        mentionSkillsUnavailable={mentions?.mentionSkillsUnavailable}
+        mentionSkillsLoading={mentions?.mentionSkillsLoading}
+        onSearchMentionFiles={mentions?.searchMentionFiles}
         hidden={!active || onboardingComposerHidden || Boolean(activeInteraction)}
         draftKey={activeId ?? newTaskDraftKey}
         draftPersistence={newTaskDraftPersistence}
diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx
index eb0dc1a91..87afd71ee 100644
--- a/apps/desktop/src/renderer/chat-message-surface.tsx
+++ b/apps/desktop/src/renderer/chat-message-surface.tsx
@@ -39,7 +39,7 @@ import type { TaskReadinessNotice } from './task-readiness-notice';
 import { getShellCopy } from './locales/shell-copy';
 import { getDesktopConversationCopy } from './locales/conversation-copy';
 import { selectLiveTurn } from './use-app-shell-session-ui-reads';
-import { useAppShellSessionUiSelector } from './use-app-shell-session-ui-selector';
+import { useExternalStoreSelector } from './use-external-store-selector';
 import { useDeepResearchRun } from './use-deep-research-run';
 
 const selectShellRunRecord = (state: AppShellSessionUiState, sessionId: string | undefined) =>
@@ -159,7 +159,7 @@ export function ChatMessageSurface({
     activeSession?.id,
     isDeepResearchSession(activeSession?.labels),
   );
-  const liveTurn = useAppShellSessionUiSelector(sessionUiController, selectLiveTurn, activeSessionId);
+  const liveTurn = useExternalStoreSelector(sessionUiController, selectLiveTurn, activeSessionId);
   const seededLiveTurn = liveContentSeedRevision > 0 ? liveTurn : undefined;
   const [activation, setActivation] = useState(() => ({
     sessionId: activeSessionId,
@@ -193,7 +193,7 @@ export function ChatMessageSurface({
   // change to any OTHER map cannot rebuild the array. Deriving it in the
   // selector would need a comparator to say the same thing, and would still
   // recompute once per store change.
-  const shellRunUpdateRecord = useAppShellSessionUiSelector(
+  const shellRunUpdateRecord = useExternalStoreSelector(
     sessionUiController,
     selectShellRunRecord,
     activeSessionId,
diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/composer-mentions.tsx
similarity index 78%
rename from apps/desktop/src/renderer/use-composer-mentions.ts
rename to apps/desktop/src/renderer/composer-mentions.tsx
index ea95b268d..4a4eeb62f 100644
--- a/apps/desktop/src/renderer/use-composer-mentions.ts
+++ b/apps/desktop/src/renderer/composer-mentions.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { useCallback, useEffect, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
 import type { ChatDefaultPermissionMode } from '@maka/core/settings';
 import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation';
 import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js';
@@ -46,15 +46,16 @@ function invocableSkillListsEqual(
   });
 }
 
-/**
- * Owns the composer mention popup wiring so app-shell.tsx keeps no inline
- * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives
- * the `/` popup's skill list from Runtime's authoritative invocable projection, and
- * exposes a fail-soft file-search callback backed by the `workspace:searchFiles`
- * IPC. Both return values are memoized so the Composer props keep stable
- * identities across renders.
- */
-export function useComposerMentions(options: {
+/** What the composer needs to render its `/` and `@` popups. */
+export interface ComposerMentions {
+  mentionSkills: ReadonlyArray<{ ref?: string; id: string; name: string; description?: string }>;
+  mentionSkillsUnavailable: boolean;
+  mentionSkillsLoading: boolean;
+  searchMentionFiles(query: string): Promise<ReadonlyArray<{ relativePath: string }>>;
+}
+
+/** Which backend surface the popups should describe. */
+export interface ComposerMentionsSurface {
   /** Invalidates Runtime's invocable projection after installed Skills settle. */
   skillCatalogRevision: number;
   sessionId?: string;
@@ -63,12 +64,17 @@ export function useComposerMentions(options: {
   newSessionCollaborationMode?: 'agent' | 'plan';
   newSessionPermissionMode?: ChatDefaultPermissionMode;
   newTaskTarget?: DesktopNewTaskTarget;
-}): {
-  mentionSkills: ReadonlyArray<{ ref?: string; id: string; name: string; description?: string }>;
-  mentionSkillsUnavailable: boolean;
-  mentionSkillsLoading: boolean;
-  searchMentionFiles(query: string): Promise<ReadonlyArray<{ relativePath: string }>>;
-} {
+}
+
+/**
+ * Owns the composer mention popup wiring so app-shell.tsx keeps no inline
+ * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives
+ * the `/` popup's skill list from Runtime's authoritative invocable projection, and
+ * exposes a fail-soft file-search callback backed by the `workspace:searchFiles`
+ * IPC. Both return values are memoized so the Composer props keep stable
+ * identities across renders.
+ */
+function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions {
   const {
     projectPath,
     sessionId,
@@ -116,7 +122,7 @@ export function useComposerMentions(options: {
     loading: boolean;
     settled?: 'empty' | 'populated';
     skills: InvocableSkillEntry[];
-  }>({ contextKey, loading: true, skills: [] });
+  }>({ contextKey, loading: true, skills: EMPTY_SKILLS });
   const liveCatalog = catalog.contextKey === contextKey
     ? catalog
     : { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS };
@@ -139,7 +145,7 @@ export function useComposerMentions(options: {
             { ...previous, loading: true }
           : // A context switch has nothing settled to hold, and its Skills
             // belong to the surface being left behind.
-            { contextKey, loading: true, settled: undefined, skills: [] },
+            { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS },
       );
       const context = {
         ...(newSessionModel ?? {}),
@@ -174,7 +180,7 @@ export function useComposerMentions(options: {
           // Fail soft: an unavailable projection leaves `/` with no suggestions.
           // Direct `/skill:<id>` input still reaches the same Runtime resolver.
           if (cancelled || version !== requestVersion) return;
-          setCatalog({ contextKey, loading: false, settled: 'empty', skills: [] });
+          setCatalog({ contextKey, loading: false, settled: 'empty', skills: EMPTY_SKILLS });
         },
       );
     };
@@ -243,3 +249,53 @@ export function useComposerMentions(options: {
     searchMentionFiles,
   };
 }
+
+/**
+ * Undefined, not an empty projection. A composer rendered outside the shell —
+ * the draft-handoff suite mounts `ChatComposerRegion` on its own — must see
+ * exactly what it saw when these arrived as optional props: nothing. Standing
+ * in an empty catalog instead flips `onSearchMentionFiles` from absent to
+ * present, and the Composer mounts the mention popup's layer for a surface
+ * that has no catalog behind it.
+ */
+const ComposerMentionsContext = createContext<ComposerMentions | undefined>(undefined);
+
+/**
+ * Publishes the mention projection to whichever composers are on screen.
+ *
+ * The catalog reloads on every session switch and on every MCP or session
+ * change event, several times per switch. Holding it in AppShell put those
+ * reloads above the whole tree, so each one re-rendered ~1600 components to
+ * repaint two popups. Owning it here keeps the reload inside this provider:
+ * `children` is the element AppShell already built, so React bails out of the
+ * subtree and only the composers that read the context re-render.
+ */
+export function ComposerMentionsProvider({
+  children,
+  ...surface
+}: ComposerMentionsSurface & { children: ReactNode }) {
+  const {
+    mentionSkills,
+    mentionSkillsUnavailable,
+    mentionSkillsLoading,
+    searchMentionFiles,
+  } = useComposerMentions(surface);
+  // Destructured so the dependencies ARE the materials. Memoizing the returned
+  // object against a hand-listed mirror of its fields reads the same until a
+  // fifth field is added and not mirrored — then consumers keep a wholly stale
+  // value, and no lint rule can see it.
+  const value = useMemo(
+    () => ({
+      mentionSkills,
+      mentionSkillsUnavailable,
+      mentionSkillsLoading,
+      searchMentionFiles,
+    }),
+    [mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles],
+  );
+  return <ComposerMentionsContext.Provider value={value}>{children}</ComposerMentionsContext.Provider>;
+}
+
+export function useComposerMentionsContext(): ComposerMentions | undefined {
+  return useContext(ComposerMentionsContext);
+}
diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md
index 7fadedaf4..498d7d4c1 100644
--- a/apps/desktop/src/renderer/features/session-navigation/README.md
+++ b/apps/desktop/src/renderer/features/session-navigation/README.md
@@ -45,14 +45,26 @@ WorkHub exit, active-Session selection, transcript clearing, and renderer-state
 cleanup. Session Navigation does not own catalog authority, transcript/runtime
 state, Session controls, task submission, or Module Hub routing.
 
+Those intents stay intents. Opening a Session also clears the active transcript
+and leaves WorkHub, and the rail does not subscribe to either: it calls them
+through `SessionNavigationPorts`, which the shell composes.
+
 ## Public surface
 
-- `useSessionNavigationController` owns layout, projections, jumps, and row
-  mutation commands.
-- `<SessionNavigationHost>` maps that controller onto the complete
-  `SessionListPanel` surface.
-- `selectors.activeParentSession`, `branchBanner`, and `revisionNavigation`
-  are the narrow projections still consumed by shell/conversation chrome.
+- `<SessionNavigationProvider>` is where the rail's state lives. It calls
+  `useSessionNavigationController` and publishes what the rail reads as two
+  contexts — its data and its chrome — so a re-render of AppShell is not a
+  re-render of the rail (#4109).
+- `useSessionNavigationController` owns layout, projections, and row mutation
+  commands. It is called by the provider and nowhere else; calling it in a
+  render body above the rail is what put the rail's state on the whole tree.
+- `useSessionNavigationReads` is the shell's own narrow read: the rail
+  projection it also needs for the command palette, the branch banner, the
+  revision navigation, and the rail's width. It holds no state.
+- `createSessionOpenCommand` composes an explicit Session jump out of the
+  shell's own actions.
+- `sessionRailLayoutStore` owns collapse, width, and grouping mode, with the
+  existing persistence keys.
 
 ## Lifecycle invariants
 
diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts
new file mode 100644
index 000000000..9980caa68
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export type SessionNavigationSearchTarget = {
+  sessionId: string;
+  turnId: string;
+  sequence?: number;
+  nonce: number;
+};
+
+export interface SessionOpenCommandDeps {
+  activateSession(sessionId: string): void;
+  exitWorkHub(): void;
+  selectSessionSurface(): void;
+  setSearchTarget(target: SessionNavigationSearchTarget | null): void;
+}
+
+/**
+ * Opening a Session is four intents in one order, spelled out.
+ *
+ * Leave the Work Hub, show the Session surface, move the selection, then aim
+ * the search scroll — or clear it, when no turn was named. The rail calls this;
+ * it subscribes to none of the four. Keeping the composition in one named
+ * factory is what lets the order stay asserted after the controller's call site
+ * moved below the shell (#4109).
+ */
+export function createSessionOpenCommand(deps: SessionOpenCommandDeps) {
+  return (sessionId: string, turnId?: string, sequence?: number): void => {
+    deps.exitWorkHub();
+    deps.selectSessionSurface();
+    deps.activateSession(sessionId);
+    deps.setSearchTarget(
+      turnId ? { sessionId, turnId, sequence, nonce: Date.now() } : null,
+    );
+  };
+}
diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts
index 30dfa3de8..b7207ad07 100644
--- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts
@@ -17,42 +17,20 @@
  * under the License.
  */
 
-import {
-  useCallback,
-  useEffect,
-  useLayoutEffect,
-  useMemo,
-  useRef,
-  useState,
-  type Dispatch,
-  type RefObject,
-  type SetStateAction,
-} from 'react';
+import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
 import type { ProjectRecord } from '@maka/core/project';
 import type { SessionSummary } from '@maka/core/session';
 import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind';
-import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav';
-import { useUiLocale, type SessionHistoryGroup, type SessionViewMode } from '@maka/ui';
-import { safeLocalStorageSet } from '../../../browser-storage.js';
-import { useStableActions } from '../../../use-stable-actions.js';
-import type { BranchBanner } from '../model/branch-banner.js';
-import { deriveBranchBanner } from '../model/branch-banner.js';
-import {
-  readSessionListCollapsed,
-  readSessionListViewMode,
-  readSessionListWidth,
-  SESSION_LIST_EXPANDED_MAX_WIDTH,
-  SESSION_LIST_EXPANDED_MIN_WIDTH,
-  writeSessionListViewMode,
-} from '../model/session-list-layout.js';
+import { useUiLocale, type SessionHistoryGroup } from '@maka/ui';
+import { useExternalStoreSelector } from '../../../use-external-store-selector.js';
 import { deriveSessionNavigationGroups } from '../model/session-navigation-groups.js';
-import { sessionMatchesRail } from '../model/session-nav-filter.js';
 import { deriveWorktreeSessionIds } from '../model/session-project-grouping.js';
-import { deriveSessionRail } from '../model/session-rail.js';
+import type { SessionRailProjection } from '../model/session-rail.js';
 import {
-  deriveSessionRevisionNavigation,
-  type SessionRevisionNavigation,
-} from '../model/session-revisions.js';
+  selectRailLayout,
+  sessionRailLayoutStore,
+  type SessionRailLayoutState,
+} from '../model/session-rail-layout-store.js';
 import type { SessionNavigationSession } from '../ports.js';
 import { useSessionNavigationServices } from '../services-context.js';
 import {
@@ -60,15 +38,6 @@ import {
   type SessionNavigationRowActions,
 } from './session-row-actions.js';
 
-const LAYOUT_PERSIST_DEBOUNCE_MS = 200;
-
-export type SessionNavigationSearchTarget = {
-  sessionId: string;
-  turnId: string;
-  sequence?: number;
-  nonce: number;
-};
-
 export type SessionNavigationToastApi = {
   success(title: string, description?: string): void;
   error(
@@ -86,131 +55,108 @@ export type SessionNavigationToastApi = {
   }): Promise<boolean>;
 };
 
-export interface UseSessionNavigationControllerInput {
-  sessions: readonly SessionNavigationSession[];
-  activeSessionId: string | undefined;
-  hiddenSessionIds: ReadonlySet<string>;
-  projects: readonly ProjectRecord[];
+type RefBox<T> = { current: T };
+
+/**
+ * What the rail asks of the rest of the shell, named one by one.
+ *
+ * Switching a session also clears the active messages and leaves the Work Hub.
+ * Those are commands the shell issues, and they stay commands: the rail calls
+ * them, it does not subscribe to them. Nothing here has to be identity-stable —
+ * the controller reads them through a ref published on commit — so the shell
+ * may build this object inline, and no ordinary `function` declaration upstream
+ * can quietly put the rail back on every AppShell render (#4109).
+ */
+export interface SessionNavigationPorts {
+  activeIdRef: RefBox<string | undefined>;
+  sessionsRef: RefBox<ReadonlyArray<SessionSummary>>;
+  pendingSessionRowActionsRef: RefBox<Set<string>>;
   activateSession(sessionId: string | undefined): void;
   clearActiveMessages(): void;
   clearSessionRendererState(sessionId: string): void;
-  exitWorkHub(): void;
-  refreshSessions(): Promise<ReadonlyArray<SessionNavigationSession>>;
-  selectSessionSurface(): void;
-  setSearchTarget(target: SessionNavigationSearchTarget | null): void;
+  refreshSessions(): Promise<ReadonlyArray<SessionSummary>>;
   toastApi: SessionNavigationToastApi;
 }
 
-export interface SessionNavigationLayout {
-  collapsed: boolean;
-  width: number;
-  viewMode: SessionViewMode;
-  collapseHandleRef: RefObject<SideNavImperativeCollapseHandle | null>;
-  setCollapsed: Dispatch<SetStateAction<boolean>>;
-  setWidth(width: number): void;
-  setViewMode(mode: SessionViewMode): void;
+export interface UseSessionNavigationControllerInput {
+  /**
+   * The rail projection, derived once by its owner. The command palette lists
+   * the same visible sessions, so deriving it here as well would be two
+   * derivations of one reading.
+   */
+  rail: SessionRailProjection<SessionNavigationSession>;
+  projects: readonly ProjectRecord[];
+  ports: SessionNavigationPorts;
 }
 
 export interface SessionNavigationSelectors {
-  visibleSessions: SessionNavigationSession[];
-  activeRowId: string | undefined;
-  activeParentSession: SessionNavigationSession | undefined;
-  branchBanner: BranchBanner | undefined;
-  revisionNavigation: SessionRevisionNavigation | undefined;
   groups: SessionHistoryGroup[];
   worktreeSessionIds: ReadonlySet<string>;
   sessionMeta(session: SessionSummary): string | undefined;
 }
 
-export interface SessionNavigationCommands extends SessionNavigationRowActions {
-  openSession(sessionId: string, turnId?: string, sequence?: number): void;
-}
-
 export interface SessionNavigationController {
-  layout: SessionNavigationLayout;
+  layout: SessionRailLayoutState;
   selectors: SessionNavigationSelectors;
-  commands: SessionNavigationCommands;
+  commands: SessionNavigationRowActions;
 }
 
-/** Owns Session rail projection, layout persistence, jumps, and row mutations. */
+/**
+ * Owns the Session rail's projections, its geometry, and its row mutations.
+ *
+ * Called by `SessionNavigationProvider`, which sits directly above the rail.
+ * It used to be called in AppShell's render body, and that one fact — not the
+ * size of any file — put the rail's state above the whole tree (#4109).
+ */
 export function useSessionNavigationController(
   input: UseSessionNavigationControllerInput,
 ): SessionNavigationController {
   const locale = useUiLocale();
   const { sessions: service } = useSessionNavigationServices();
-  const [width, setWidth] = useState(readSessionListWidth);
-  const [collapsed, setCollapsed] = useState(readSessionListCollapsed);
-  const [viewMode, setViewMode] = useState(readSessionListViewMode);
-  const collapseHandleRef = useRef<SideNavImperativeCollapseHandle | null>(null);
-  const activeIdRef = useRef(input.activeSessionId);
-  const sessionsRef = useRef<ReadonlyArray<SessionNavigationSession>>(input.sessions);
-  const pendingSessionRowActionsRef = useRef(new Set<string>());
-
-  // Row actions can settle after the render that created them. Publish the
-  // catalog/selection pair only when that render commits so an interrupted
-  // concurrent render cannot leak an uncommitted snapshot to a live action.
+  const { ports, rail } = input;
+
+  // One subscription, not one per field: every field here is read, so a split
+  // would buy no granularity, and the store replaces its state only when a
+  // field actually moved — the identity IS the comparison.
+  const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout);
+
+  // The ports are commands, so their identity is not information. Published on
+  // commit and called through the ref, they can carry whatever the shell
+  // rebuilt this render without the rail hearing about it — which is what lets
+  // the row actions below be built once, per locale, instead of per render.
+  //
+  // This is one indirection in one place, and it replaces the alternative the
+  // shell was drifting towards: hand-stabilising every function that happens to
+  // be upstream of the rail, where a single ordinary `function` declaration
+  // anywhere in the chain silently undoes the whole thing (#4109).
+  const portsRef = useRef(ports);
   useLayoutEffect(() => {
-    activeIdRef.current = input.activeSessionId;
-    sessionsRef.current = input.sessions;
-  }, [input.activeSessionId, input.sessions]);
-
-  useEffect(() => {
-    const handle = window.setTimeout(() => {
-      safeLocalStorageSet('maka-chat-list-width-v1', String(width));
-    }, LAYOUT_PERSIST_DEBOUNCE_MS);
-    return () => window.clearTimeout(handle);
-  }, [width]);
-
-  useEffect(() => {
-    safeLocalStorageSet(
-      'maka-chat-list-collapsed-v1',
-      collapsed ? 'true' : 'false',
-    );
-  }, [collapsed]);
-
-  useEffect(() => {
-    writeSessionListViewMode(viewMode);
-  }, [viewMode]);
-
-  const rowActions = useStableActions(createSessionNavigationRowActions, {
-    uiLocale: locale,
-    activeIdRef,
-    clearActiveMessages: input.clearActiveMessages,
-    clearSessionRendererState: input.clearSessionRendererState,
-    pendingSessionRowActionsRef,
-    refreshSessions: input.refreshSessions,
-    service,
-    sessionsRef,
-    setActiveId: input.activateSession,
-    toastApi: input.toastApi,
+    portsRef.current = ports;
   });
 
-  const openSession = useCallback(
-    (sessionId: string, turnId?: string, sequence?: number): void => {
-      input.exitWorkHub();
-      input.selectSessionSurface();
-      input.activateSession(sessionId);
-      input.setSearchTarget(
-        turnId
-          ? { sessionId, turnId, sequence, nonce: Date.now() }
-          : null,
-      );
-    },
-    [
-      input.activateSession,
-      input.exitWorkHub,
-      input.selectSessionSurface,
-      input.setSearchTarget,
-    ],
-  );
-
-  const rail = useMemo(
+  const commands = useMemo(
     () =>
-      deriveSessionRail(input.sessions, input.activeSessionId, (session) =>
-        !input.hiddenSessionIds.has(session.id) && sessionMatchesRail(session),
-      ),
-    [input.activeSessionId, input.hiddenSessionIds, input.sessions],
+      createSessionNavigationRowActions({
+        uiLocale: locale,
+        activeIdRef: portsRef.current.activeIdRef,
+        clearActiveMessages: () => portsRef.current.clearActiveMessages(),
+        clearSessionRendererState: (sessionId) =>
+          portsRef.current.clearSessionRendererState(sessionId),
+        pendingSessionRowActionsRef: portsRef.current.pendingSessionRowActionsRef,
+        refreshSessions: () => portsRef.current.refreshSessions(),
+        service,
+        sessionsRef: portsRef.current.sessionsRef,
+        setActiveId: (sessionId) => portsRef.current.activateSession(sessionId),
+        toastApi: {
+          success: (title, description) => portsRef.current.toastApi.success(title, description),
+          error: (title, description, details, target) =>
+            portsRef.current.toastApi.error(title, description, details, target),
+          confirm: (options) => portsRef.current.toastApi.confirm(options),
+        },
+      }),
+    [locale, service],
   );
+
   const groups = useMemo(
     () => deriveSessionNavigationGroups(rail.sessions, input.projects, locale),
     [locale, input.projects, rail.sessions],
@@ -225,24 +171,13 @@ export function useSessionNavigationController(
       ),
     [input.projects, rail.sessions],
   );
-  const activeSession = input.sessions.find(
-    (session) => session.id === input.activeSessionId,
-  );
-  const branchBanner = useMemo(
-    () => deriveBranchBanner(activeSession, input.sessions),
-    [activeSession, input.sessions],
-  );
-  const revisionNavigation = useMemo(
-    () => deriveSessionRevisionNavigation(input.sessions, input.activeSessionId),
-    [input.activeSessionId, input.sessions],
-  );
   const sessionById = useMemo(
-    () => new Map(input.sessions.map((session) => [session.id, session])),
-    [input.sessions],
+    () => new Map(rail.sessions.map((session) => [session.id, session])),
+    [rail.sessions],
   );
   const sessionMeta = useCallback(
     (session: SessionSummary): string | undefined => {
-      const projected = sessionById.get(session.id);
+      const projected: SessionNavigationSession | undefined = sessionById.get(session.id);
       return projected && runtimeHostProfileUsesHostWorkspace(projected.profileKind)
         ? projected.profileName
         : undefined;
@@ -250,37 +185,9 @@ export function useSessionNavigationController(
     [sessionById],
   );
 
-  const layout = useMemo<SessionNavigationLayout>(
-    () => ({
-      collapsed,
-      width,
-      viewMode,
-      collapseHandleRef,
-      setCollapsed,
-      setWidth,
-      setViewMode,
-    }),
-    [collapsed, viewMode, width],
-  );
   const selectors = useMemo<SessionNavigationSelectors>(
-    () => ({
-      visibleSessions: rail.sessions,
-      activeRowId: rail.activeRowId,
-      activeParentSession: rail.activeParentSession,
-      branchBanner,
-      revisionNavigation,
-      groups,
-      worktreeSessionIds,
-      sessionMeta,
-    }),
-    [branchBanner, groups, rail, revisionNavigation, sessionMeta, worktreeSessionIds],
-  );
-  const commands = useMemo<SessionNavigationCommands>(
-    () => ({
-      ...rowActions,
-      openSession,
-    }),
-    [openSession, rowActions],
+    () => ({ groups, worktreeSessionIds, sessionMeta }),
+    [groups, sessionMeta, worktreeSessionIds],
   );
 
   return useMemo(
diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts
new file mode 100644
index 000000000..a84bc26d9
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import { useExternalStoreSelector } from '../../../use-external-store-selector.js';
+import { deriveBranchBanner, type BranchBanner } from '../model/branch-banner.js';
+import { sessionMatchesRail } from '../model/session-nav-filter.js';
+import { deriveSessionRail, type SessionRailProjection } from '../model/session-rail.js';
+import {
+  selectRailLayout,
+  sessionRailLayoutStore,
+  type SessionRailLayoutState,
+} from '../model/session-rail-layout-store.js';
+import {
+  deriveSessionRevisionNavigation,
+  type SessionRevisionNavigation,
+} from '../model/session-revisions.js';
+import type { SessionNavigationSession } from '../ports.js';
+
+export interface SessionNavigationReads {
+  /** The rail's membership, derived once and shared with the command palette. */
+  rail: SessionRailProjection<SessionNavigationSession>;
+  branchBanner: BranchBanner | undefined;
+  revisionNavigation: SessionRevisionNavigation | undefined;
+  layout: SessionRailLayoutState;
+}
+
+/**
+ * What the shell reads from Session Navigation, as opposed to what it owns.
+ *
+ * Nothing here holds state: three `useMemo`s over the catalog the shell already
+ * has, and one subscription to the rail's geometry — which the window frame
+ * needs, because `--maka-sidenav-width` is where the titlebar's breadcrumb
+ * starts. The rail's own state lives under `SessionNavigationProvider` and is
+ * not visible from here, which is the point of #4109: a hook called in the
+ * shell's render body has the whole tree as its scope, so the ones that remain
+ * had better hold nothing.
+ */
+export function useSessionNavigationReads(input: {
+  sessions: readonly SessionNavigationSession[];
+  activeSessionId: string | undefined;
+  activeSession: SessionNavigationSession | undefined;
+  hiddenSessionIds: ReadonlySet<string>;
+}): SessionNavigationReads {
+  const { activeSession, activeSessionId, hiddenSessionIds, sessions } = input;
+  const rail = useMemo(
+    () =>
+      deriveSessionRail(sessions, activeSessionId, (session) =>
+        !hiddenSessionIds.has(session.id) && sessionMatchesRail(session),
+      ),
+    [activeSessionId, hiddenSessionIds, sessions],
+  );
+  const branchBanner = useMemo(
+    () => deriveBranchBanner(activeSession, sessions),
+    [activeSession, sessions],
+  );
+  const revisionNavigation = useMemo(
+    () => deriveSessionRevisionNavigation(sessions, activeSessionId),
+    [activeSessionId, sessions],
+  );
+  const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout);
+  return { rail, branchBanner, revisionNavigation, layout };
+}
diff --git a/apps/desktop/src/renderer/features/session-navigation/index.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts
index 17ce0481e..9acddf88c 100644
--- a/apps/desktop/src/renderer/features/session-navigation/index.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/index.ts
@@ -18,8 +18,14 @@
  */
 
 export { SessionNavigationServicesProvider } from './services-context.js';
-export { useSessionNavigationController } from './controller/use-session-navigation-controller.js';
-export { SessionNavigationHost } from './ui/session-navigation-host.js';
+export { SessionNavigationProvider } from './ui/session-navigation-provider.js';
+export { createSessionOpenCommand } from './controller/session-open-command.js';
+export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js';
+export type { SessionNavigationPorts } from './controller/use-session-navigation-controller.js';
 export { deriveSessionRail } from './model/session-rail.js';
-export type { SessionPurgeOutcome } from './controller/session-row-actions.js';
+export { sessionRailLayoutStore } from './model/session-rail-layout-store.js';
+export type {
+  SessionNavigationRowActions,
+  SessionPurgeOutcome,
+} from './controller/session-row-actions.js';
 export type { SessionNavigationServices } from './ports.js';
diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts
new file mode 100644
index 000000000..e9aa78e26
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav';
+import type { SessionViewMode } from '@maka/ui';
+import { safeLocalStorageSet } from '../../../browser-storage.js';
+import { createObservableState } from '../../../observable-state.js';
+import {
+  clampSessionListWidth,
+  readSessionListCollapsed,
+  readSessionListViewMode,
+  readSessionListWidth,
+  SESSION_LIST_EXPANDED_MIN_WIDTH,
+  writeSessionListViewMode,
+} from './session-list-layout.js';
+
+const LAYOUT_PERSIST_DEBOUNCE_MS = 200;
+
+export interface SessionRailLayoutState {
+  readonly collapsed: boolean;
+  readonly width: number;
+  readonly viewMode: SessionViewMode;
+}
+
+/**
+ * The rail's own geometry, as a store rather than `useState` in the shell
+ * (#4109).
+ *
+ * Two readers want it and they are on opposite sides of the rail: the rail
+ * renders at this width, and the window frame publishes it as
+ * `--maka-sidenav-width` so the titlebar's session breadcrumb starts where the
+ * column ends. Held as shell state it re-rendered the whole tree on every drag
+ * frame; held here each side subscribes to the reading it uses.
+ *
+ * One rail exists per renderer and its persisted form is already a single
+ * localStorage record, so the store is a module value: there is no second
+ * instance for it to be an instance of.
+ */
+export function createSessionRailLayoutStore() {
+  const state = createObservableState<SessionRailLayoutState>({
+    collapsed: readSessionListCollapsed(),
+    width: readSessionListWidth(),
+    viewMode: readSessionListViewMode(),
+  });
+  const collapseHandleRef: { current: SideNavImperativeCollapseHandle | null } = { current: null };
+  let widthPersistHandle: ReturnType<typeof setTimeout> | undefined;
+
+  return {
+    getState: state.getState,
+    subscribe: state.subscribe,
+    collapseHandleRef,
+    setCollapsed(next: boolean): void {
+      const current = state.getState();
+      if (current.collapsed === next) return;
+      state.replaceState({ ...current, collapsed: next });
+      safeLocalStorageSet('maka-chat-list-collapsed-v1', next ? 'true' : 'false');
+    },
+    /** Debounced: a drag reports a width per frame and only the last one is worth storing. */
+    setWidth(next: number): void {
+      // Astryx reports a collapse as `onSizeChange(0)`, from the button and from
+      // a drag past the collapse threshold alike. That zero is the collapsed
+      // geometry, not a width the user chose, and clamping it would overwrite
+      // the remembered expanded width with the minimum. Every real width Astryx
+      // reports is already clamped to `minWidth`, so anything below it is the
+      // sentinel. The guard lives here rather than at the call site because the
+      // call site moves — that is exactly how it was lost (#4109).
+      if (next < SESSION_LIST_EXPANDED_MIN_WIDTH) return;
+      const current = state.getState();
+      const width = clampSessionListWidth(next);
+      if (current.width === width) return;
+      state.replaceState({ ...current, width });
+      if (widthPersistHandle !== undefined) clearTimeout(widthPersistHandle);
+      widthPersistHandle = setTimeout(() => {
+        safeLocalStorageSet('maka-chat-list-width-v1', String(width));
+      }, LAYOUT_PERSIST_DEBOUNCE_MS);
+    },
+    setViewMode(next: SessionViewMode): void {
+      const current = state.getState();
+      if (current.viewMode === next) return;
+      state.replaceState({ ...current, viewMode: next });
+      writeSessionListViewMode(next);
+    },
+  };
+}
+
+export type SessionRailLayoutStore = ReturnType<typeof createSessionRailLayoutStore>;
+
+export const sessionRailLayoutStore: SessionRailLayoutStore = createSessionRailLayoutStore();
+
+/**
+ * The whole geometry. Both readers use more than one field of it, and the store
+ * replaces its state only when a field actually moved, so the identity is
+ * already the comparison — a per-field selector would buy no granularity.
+ */
+export const selectRailLayout = (state: SessionRailLayoutState): SessionRailLayoutState => state;
diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts
index f39ec8585..b9017a2d3 100644
--- a/apps/desktop/src/renderer/features/session-navigation/testing.ts
+++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts
@@ -29,11 +29,15 @@ export { SessionNavigationServicesProvider } from './services-context.js';
 export {
   createSessionNavigationRowActions,
 } from './controller/session-row-actions.js';
+export { createSessionOpenCommand } from './controller/session-open-command.js';
 export {
   useSessionNavigationController,
   type SessionNavigationController,
+  type SessionNavigationPorts,
   type UseSessionNavigationControllerInput,
 } from './controller/use-session-navigation-controller.js';
+export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js';
+export { sessionMatchesRail } from './model/session-nav-filter.js';
 export { deriveBranchBanner } from './model/branch-banner.js';
 export { deriveSessionRail } from './model/session-rail.js';
 export { deriveSessionRevisionNavigation } from './model/session-revisions.js';
@@ -41,6 +45,7 @@ export {
   readSessionListViewMode,
   writeSessionListViewMode,
 } from './model/session-list-layout.js';
+export { createSessionRailLayoutStore } from './model/session-rail-layout-store.js';
 
 export function createFakeSessionNavigationServices(
   overrides: Partial<SessionNavigationServices> = {},
diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx
deleted file mode 100644
index cc38c421d..000000000
--- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { useMemo, type ComponentProps } from 'react';
-import { SessionListPanel } from '@maka/ui';
-import type { SessionNavigationController } from '../controller/use-session-navigation-controller.js';
-import {
-  SESSION_LIST_EXPANDED_MAX_WIDTH,
-  SESSION_LIST_EXPANDED_MIN_WIDTH,
-} from '../model/session-list-layout.js';
-
-type PanelProps = ComponentProps<typeof SessionListPanel>;
-
-export type SessionNavigationHostProps = Pick<
-  PanelProps,
-  | 'selection'
-  | 'scheduledTasks'
-  | 'streamingSessionIds'
-  | 'staleSessionIds'
-  | 'moduleMemory'
-  | 'onSelect'
-  | 'onOpenSettings'
-  | 'updateReminder'
-  | 'onOpenUpdate'
-  | 'onNew'
-  | 'workHubEntry'
-  | 'projectActions'
-> & {
-  controller: SessionNavigationController;
-  onExitWorkHub(): void;
-  workHubActive: boolean;
-};
-
-/** Renders the complete Session navigation rail from its feature controller. */
-export function SessionNavigationHost(props: SessionNavigationHostProps) {
-  const { controller } = props;
-  const rowActions = useMemo<NonNullable<PanelProps['rowActions']>>(
-    () => ({
-      onToggleFlag: (sessionId, next) => {
-        void controller.commands.flagSession(sessionId, next);
-      },
-      onArchive: (sessionId) => {
-        void controller.commands.archiveSession(sessionId);
-      },
-      onUnarchive: (sessionId) => {
-        void controller.commands.unarchiveSession(sessionId);
-      },
-      onRename: (sessionId, name) => {
-        void controller.commands.renameSession(sessionId, name);
-      },
-      onDelete: (sessionId) => {
-        void controller.commands.deleteSession(sessionId);
-      },
-    }),
-    [controller.commands],
-  );
-
-  return (
-    <SessionListPanel
-      collapseHandleRef={controller.layout.collapseHandleRef}
-      collapsed={controller.layout.collapsed}
-      onCollapsedChange={controller.layout.setCollapsed}
-      width={controller.layout.width}
-      onWidthChange={(width) => {
-        if (width >= SESSION_LIST_EXPANDED_MIN_WIDTH) {
-          controller.layout.setWidth(width);
-        }
-      }}
-      minWidth={SESSION_LIST_EXPANDED_MIN_WIDTH}
-      maxWidth={SESSION_LIST_EXPANDED_MAX_WIDTH}
-      selection={props.selection}
-      sessions={controller.selectors.visibleSessions}
-      activeId={props.workHubActive ? undefined : controller.selectors.activeRowId}
-      scheduledTasks={props.scheduledTasks}
-      streamingSessionIds={props.streamingSessionIds}
-      staleSessionIds={props.staleSessionIds}
-      viewMode={controller.layout.viewMode}
-      onViewModeChange={controller.layout.setViewMode}
-      groups={
-        controller.layout.viewMode === 'project'
-          ? controller.selectors.groups
-          : undefined
-      }
-      worktreeSessionIds={controller.selectors.worktreeSessionIds}
-      sessionMeta={controller.selectors.sessionMeta}
-      moduleMemory={props.moduleMemory}
-      onSelect={(selection) => {
-        props.onExitWorkHub();
-        props.onSelect(selection);
-      }}
-      onSelectSession={controller.commands.openSession}
-      onOpenSettings={props.onOpenSettings}
-      updateReminder={props.updateReminder}
-      onOpenUpdate={props.onOpenUpdate}
-      onNew={() => {
-        props.onExitWorkHub();
-        props.onNew();
-      }}
-      workHubEntry={props.workHubEntry}
-      rowActions={rowActions}
-      projectActions={props.projectActions}
-    />
-  );
-}
diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
new file mode 100644
index 000000000..c7322afe2
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
+import type { ProjectRecord } from '@maka/core/project';
+import type { ScheduledTask } from '@maka/core/scheduled-task';
+import {
+  SessionRailProvider,
+  type NavModuleMemory,
+  type NavSelection,
+  type ProjectRowActions,
+  type SessionRailChrome,
+  type SessionRailData,
+  type SessionRowActions,
+  type SidebarUpdateReminder,
+} from '@maka/ui';
+import {
+  useSessionNavigationController,
+  type SessionNavigationPorts,
+} from '../controller/use-session-navigation-controller.js';
+import type { SessionNavigationRowActions } from '../controller/session-row-actions.js';
+import {
+  SESSION_LIST_EXPANDED_MAX_WIDTH,
+  SESSION_LIST_EXPANDED_MIN_WIDTH,
+} from '../model/session-list-layout.js';
+import type { SessionRailProjection } from '../model/session-rail.js';
+import { sessionRailLayoutStore } from '../model/session-rail-layout-store.js';
+import type { SessionNavigationSession } from '../ports.js';
+
+/** The chrome the shell owns and the rail only displays. */
+export interface SessionNavigationChromeInput {
+  selection: NavSelection;
+  scheduledTasks?: readonly ScheduledTask[];
+  moduleMemory?: NavModuleMemory;
+  updateReminder?: SidebarUpdateReminder;
+  workHubActive: boolean;
+  workHubEntry?: { active: boolean; label: string; onSelect(): void };
+  projectActions?: ProjectRowActions;
+  onSelect(selection: NavSelection): void;
+  onOpenSettings(): void;
+  onOpenUpdate?(): void;
+  onNew(): void;
+  onExitWorkHub(): void;
+  onSelectSession(sessionId: string): void;
+}
+
+export interface SessionNavigationProviderProps extends SessionNavigationChromeInput {
+  rail: SessionRailProjection<SessionNavigationSession>;
+  projects: readonly ProjectRecord[];
+  streamingSessionIds: ReadonlySet<string>;
+  staleSessionIds: ReadonlySet<string>;
+  ports: SessionNavigationPorts;
+  /**
+   * Where the shell reads the row mutations it issues from elsewhere — the
+   * archived-tasks sweep, the chat header's rename. They are calls made from
+   * event handlers, never values read during a render, so a ref is the whole
+   * carrier they need and the shell does not re-render to receive them.
+   */
+  commandsRef: { current: SessionNavigationRowActions | null };
+  children?: ReactNode;
+}
+
+/**
+ * The Session rail's own scope (#4109).
+ *
+ * The shell renders this on every one of its ~14 commits per session switch,
+ * and that is fine: this component is one fiber, its `children` element is
+ * built once by the shell, and React skips an unchanged child. What reaches the
+ * rail below is the two context values — and those change when the rail's data
+ * changes, not when the shell renders. The rail's ~1,000 fibers are on the
+ * first, the few dozen fibers of permanent chrome on the second.
+ */
+export function SessionNavigationProvider(props: SessionNavigationProviderProps) {
+  const controller = useSessionNavigationController({
+    rail: props.rail,
+    projects: props.projects,
+    ports: props.ports,
+  });
+
+  useLayoutEffect(() => {
+    props.commandsRef.current = controller.commands;
+  }, [controller.commands, props.commandsRef]);
+
+  const rowActions = useMemo<SessionRowActions>(
+    () => ({
+      onToggleFlag: (sessionId, next) => {
+        void controller.commands.flagSession(sessionId, next);
+      },
+      onArchive: (sessionId) => {
+        void controller.commands.archiveSession(sessionId);
+      },
+      onUnarchive: (sessionId) => {
+        void controller.commands.unarchiveSession(sessionId);
+      },
+      onRename: (sessionId, name) => {
+        void controller.commands.renameSession(sessionId, name);
+      },
+      onDelete: (sessionId) => {
+        void controller.commands.deleteSession(sessionId);
+      },
+    }),
+    [controller.commands],
+  );
+
+  // Project row mutations are commands too, and they arrive from a different
+  // feature entirely. Read through a ref for the same reason as the ports: what
+  // the rail needs is that they can be CALLED, and their identity says nothing
+  // about whether a row should be redrawn. Only their presence does, so that is
+  // what this depends on.
+  const chromeRef = useRef(props);
+  useLayoutEffect(() => {
+    chromeRef.current = props;
+  });
+  const hasProjectActions = props.projectActions !== undefined;
+  const hasRelink = props.projectActions?.onRelink !== undefined;
+  const projectActions = useMemo<ProjectRowActions | undefined>(
+    () =>
+      hasProjectActions
+        ? {
+            onNew: (projectId) => chromeRef.current.projectActions?.onNew(projectId),
+            onRename: (projectId, name) =>
+              chromeRef.current.projectActions?.onRename(projectId, name),
+            onArchive: (projectId) => chromeRef.current.projectActions?.onArchive(projectId),
+            onRestore: (projectId) => chromeRef.current.projectActions?.onRestore(projectId),
+            ...(hasRelink
+              ? {
+                  onRelink: (projectId: string) =>
+                    chromeRef.current.projectActions?.onRelink?.(projectId),
+                }
+              : {}),
+          }
+        : undefined,
+    [hasProjectActions, hasRelink],
+  );
+
+  const data = useMemo<SessionRailData>(
+    () => ({
+      sessions: props.rail.sessions,
+      activeId: props.workHubActive ? undefined : props.rail.activeRowId,
+      streamingSessionIds: props.streamingSessionIds,
+      staleSessionIds: props.staleSessionIds,
+      worktreeSessionIds: controller.selectors.worktreeSessionIds,
+      groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined,
+      groupVariant: controller.layout.viewMode,
+      sessionMeta: controller.selectors.sessionMeta,
+      onSelectSession: props.onSelectSession,
+      rowActions,
+      projectActions,
+    }),
+    [
+      controller.layout.viewMode,
+      controller.selectors.groups,
+      controller.selectors.sessionMeta,
+      controller.selectors.worktreeSessionIds,
+      props.onSelectSession,
+      projectActions,
+      props.rail,
+      props.staleSessionIds,
+      props.streamingSessionIds,
+      props.workHubActive,
+      rowActions,
+    ],
+  );
+
+  // Deliberately NOT memoized. Its readers are the nav rows and the footer —
+  // a few dozen fibers — and every field on it follows the shell, so a
+  // comparator here would run more often than it would save.
+  const chrome: SessionRailChrome = {
+    collapsed: controller.layout.collapsed,
+    onCollapsedChange: sessionRailLayoutStore.setCollapsed,
+    collapseHandleRef: sessionRailLayoutStore.collapseHandleRef,
+    width: controller.layout.width,
+    onWidthChange: sessionRailLayoutStore.setWidth,
+    minWidth: SESSION_LIST_EXPANDED_MIN_WIDTH,
+    maxWidth: SESSION_LIST_EXPANDED_MAX_WIDTH,
+    viewMode: controller.layout.viewMode,
+    onViewModeChange: sessionRailLayoutStore.setViewMode,
+    selection: props.selection,
+    scheduledTasks: props.scheduledTasks,
+    moduleMemory: props.moduleMemory,
+    onSelect: (selection) => {
+      props.onExitWorkHub();
+      props.onSelect(selection);
+    },
+    onNew: () => {
+      props.onExitWorkHub();
+      props.onNew();
+    },
+    onOpenSettings: props.onOpenSettings,
+    updateReminder: props.updateReminder,
+    onOpenUpdate: props.onOpenUpdate,
+    workHubEntry: props.workHubEntry,
+  };
+
+  return (
+    <SessionRailProvider data={data} chrome={chrome}>
+      {props.children}
+    </SessionRailProvider>
+  );
+}
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
index 743aae813..554d2a73a 100644
--- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
+++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
@@ -29,7 +29,7 @@ import {
 import type { QuoteRef } from '@maka/core/events';
 import type { SessionSummary } from '@maka/core/session';
 import { Composer, useUiLocale } from '@maka/ui';
-import type { ChatModelChoice, ComposerProps } from '@maka/ui';
+import type { ChatModelChoice } from '@maka/ui';
 import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js';
 import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
 import { localizedShellErrorMessage } from '../../../locales/shell-copy.js';
@@ -91,10 +91,6 @@ export interface UseWorkbarControllerInput {
   authoritativeSessionIds: ReadonlySet<string> | undefined;
   shellObscured: boolean;
   modelChoices: readonly ChatModelChoice[];
-  mentionSkills?: ComposerProps['mentionSkills'];
-  mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable'];
-  mentionSkillsLoading?: ComposerProps['mentionSkillsLoading'];
-  searchMentionFiles?: ComposerProps['onSearchMentionFiles'];
   reportError(title: string, description: string, sessionId: string): void;
 }
 
@@ -729,10 +725,6 @@ export function useWorkbarController(
       onActivityStateChange: sideConversations.setActive,
       sourceSession: input.activeSession,
       modelChoices: input.modelChoices,
-      mentionSkills: input.mentionSkills,
-      mentionSkillsUnavailable: input.mentionSkillsUnavailable,
-      mentionSkillsLoading: input.mentionSkillsLoading,
-      onSearchMentionFiles: input.searchMentionFiles,
       closeConfirmation: {
         key:
           pendingSideChatClose.map(({ tab }) => tab.id).join(':') || 'closed',
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index b3710f8bf..52f4ad3d5 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -31,10 +31,10 @@ import {
   type ChatModelChoice,
   type ComposerHandle,
 } from '@maka/ui';
-import type { ComposerProps } from '@maka/ui';
 import type { SessionSummary } from '@maka/core/session';
 import { useQuoteCompanion } from './use-quote-companion';
 import { useComposerAttachments } from '../../../../use-composer-attachments';
+import { useComposerMentionsContext } from '../../../../composer-mentions.js';
 import { preflightAttachmentItems } from '../../../../attachment-preflight';
 import { toComposerIngestItems } from '../../../../composer-attachments';
 import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js';
@@ -67,10 +67,6 @@ export function QuoteCompanionPanel(props: {
   sourceSession: SessionSummary | undefined;
   /** Shared global choice list, only used to render the inherited model's label. */
   modelChoices: readonly ChatModelChoice[];
-  mentionSkills?: ComposerProps['mentionSkills'];
-  mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable'];
-  mentionSkillsLoading?: ComposerProps['mentionSkillsLoading'];
-  onSearchMentionFiles?: ComposerProps['onSearchMentionFiles'];
   onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void;
   onRemoveQuote?: (target: CompanionQuoteTarget) => void;
   onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void;
@@ -81,6 +77,7 @@ export function QuoteCompanionPanel(props: {
   onActivityStateChange?: (panelId: string, active: boolean) => void;
 }) {
   const { attachments } = useWorkbarServices();
+  const mentions = useComposerMentionsContext();
   const locale = useUiLocale();
   const toast = useToast();
   const copy = getDesktopConversationCopy(locale).quoteCompanion;
@@ -269,11 +266,11 @@ export function QuoteCompanionPanel(props: {
               onAttachFilePaths={attachFilePaths}
               pendingAttachments={pendingAttachments}
               onRemoveAttachment={removeAttachment}
-              mentionSkills={props.mentionSkills}
-              onSearchMentionFiles={props.onSearchMentionFiles}
+              mentionSkills={mentions?.mentionSkills}
+              onSearchMentionFiles={mentions?.searchMentionFiles}
               pendingQuotes={props.quotes.map((quote) => quote.value)}
-              mentionSkillsUnavailable={props.mentionSkillsUnavailable}
-              mentionSkillsLoading={props.mentionSkillsLoading}
+              mentionSkillsUnavailable={mentions?.mentionSkillsUnavailable}
+              mentionSkillsLoading={mentions?.mentionSkillsLoading}
               contextDrawerDefaultCollapsed
               showStaticModelUnavailableStatus={false}
               onRemoveQuote={(index) => {
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
index 3527458ae..b30e062b8 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx
@@ -22,7 +22,6 @@ import { Card } from '@astryxdesign/core/Card';
 import { ResizeHandle, type ResizableProps } from '@astryxdesign/core/Resizable';
 import { Spinner } from '@astryxdesign/core/Spinner';
 import { Composer, useUiLocale } from '@maka/ui';
-import type { ComposerProps } from '@maka/ui';
 import type { ChatModelChoice } from '@maka/core/chat-model-choice';
 import type { SessionSummary } from '@maka/core/session';
 import { getShellCopy } from '../../../locales/shell-copy';
@@ -128,10 +127,6 @@ export interface WorkbarHostModel {
   activeSideChatPanelIds?: ReadonlySet<string>;
   sourceSession?: SessionSummary;
   modelChoices?: readonly ChatModelChoice[];
-  mentionSkills?: ComposerProps['mentionSkills'];
-  mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable'];
-  mentionSkillsLoading?: ComposerProps['mentionSkillsLoading'];
-  onSearchMentionFiles?: ComposerProps['onSearchMentionFiles'];
   closeConfirmation: {
     key: string;
     open: boolean;
@@ -215,10 +210,6 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) {
               activeSideChatPanelIds={props.activeSideChatPanelIds}
               sourceSession={props.sourceSession}
               modelChoices={props.modelChoices}
-              mentionSkills={props.mentionSkills}
-              mentionSkillsUnavailable={props.mentionSkillsUnavailable}
-              mentionSkillsLoading={props.mentionSkillsLoading}
-              onSearchMentionFiles={props.onSearchMentionFiles}
             />
           </Suspense>
         </div>
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
index ad5b2fa91..43d2bfac5 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
@@ -49,7 +49,6 @@ import {
   useUiLocale,
   type ChatModelChoice,
 } from '@maka/ui';
-import type { ComposerProps } from '@maka/ui';
 import {
   ICON_SIZE,
   Activity,
@@ -718,10 +717,6 @@ export function WorkbarSurface(props: {
   activeSideChatPanelIds?: ReadonlySet<string>;
   sourceSession?: SessionSummary;
   modelChoices?: readonly ChatModelChoice[];
-  mentionSkills?: ComposerProps['mentionSkills'];
-  mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable'];
-  mentionSkillsLoading?: ComposerProps['mentionSkillsLoading'];
-  onSearchMentionFiles?: ComposerProps['onSearchMentionFiles'];
 }) {
   const copy = getDesktopConversationCopy(useUiLocale()).workbar;
   const sessionTasks = useSessionTasks(props.sessionId);
@@ -880,10 +875,6 @@ export function WorkbarSurface(props: {
                 initialPrompt={quote.initialPrompt}
                 sourceSession={props.sourceSession}
                 modelChoices={props.modelChoices ?? []}
-                mentionSkills={props.mentionSkills}
-                mentionSkillsUnavailable={props.mentionSkillsUnavailable}
-                mentionSkillsLoading={props.mentionSkillsLoading}
-                onSearchMentionFiles={props.onSearchMentionFiles}
                 onQuotesConsumed={props.onQuotesConsumed ?? (() => {})}
                 onRemoveQuote={props.onRemoveQuote}
                 onForkVisibilityChange={props.onForkVisibilityChange}
diff --git a/apps/desktop/src/renderer/live-turn-reconciler.tsx b/apps/desktop/src/renderer/live-turn-reconciler.tsx
index fc0940d46..baea99412 100644
--- a/apps/desktop/src/renderer/live-turn-reconciler.tsx
+++ b/apps/desktop/src/renderer/live-turn-reconciler.tsx
@@ -21,7 +21,7 @@ import { useEffect } from 'react';
 import type { StoredMessage } from '@maka/core/session';
 import type { AppShellSessionUiStateController } from './app-shell-session-ui-state';
 import { selectLiveTurn } from './use-app-shell-session-ui-reads';
-import { useAppShellSessionUiSelector } from './use-app-shell-session-ui-selector';
+import { useExternalStoreSelector } from './use-external-store-selector';
 
 /**
  * Reconciles the live projection against durable messages, and renders nothing.
@@ -42,7 +42,7 @@ export function LiveTurnReconciler(props: {
   reconcile: (sessionId: string, messages: readonly StoredMessage[]) => void;
 }): null {
   const { controller, activeId, messages, reconcile } = props;
-  const liveTurn = useAppShellSessionUiSelector(controller, selectLiveTurn, activeId);
+  const liveTurn = useExternalStoreSelector(controller, selectLiveTurn, activeId);
 
   useEffect(() => {
     if (!activeId) return;
diff --git a/apps/desktop/src/renderer/observable-state.ts b/apps/desktop/src/renderer/observable-state.ts
new file mode 100644
index 000000000..df6f18153
--- /dev/null
+++ b/apps/desktop/src/renderer/observable-state.ts
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The writable half of a renderer store: one state value, replaced whole.
+ *
+ * The renderer has three of these — `app-shell-session-ui-state`,
+ * `session-catalog-state`, `session-rail-layout-store` — and they differ only
+ * in what they hold and which commands they expose. What they must NOT differ
+ * in is the notification rule below, which is load-bearing and was previously
+ * restated once per store.
+ *
+ * Pair with `useExternalStoreSelector` to read one derived value from it.
+ */
+export function createObservableState<S>(initial: S) {
+  let current = initial;
+  const listeners = new Set<() => void>();
+
+  return {
+    getState: (): S => current,
+
+    /** Subscribe to state replacements. Stable identity, for `useSyncExternalStore`. */
+    subscribe(listener: () => void): () => void {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+
+    /**
+     * Swap the state and notify, synchronously and in that order. Never
+     * schedule the notification: the terminal-turn handoff reads back the
+     * state it announces, and a selection change is read back by the handler
+     * that made it (#1985, #4109).
+     *
+     * A replacement with the same identity is not a change and notifies
+     * nobody, which is what lets a store's commands be written as plain
+     * `if (unchanged) return`.
+     */
+    replaceState(next: S): void {
+      if (next === current) return;
+      current = next;
+      for (const listener of [...listeners]) listener();
+    },
+  };
+}
diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts
new file mode 100644
index 000000000..294a73bee
--- /dev/null
+++ b/apps/desktop/src/renderer/session-catalog-state.ts
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useRef } from 'react';
+import type { DesktopSessionSummary } from '../preload/bridge-contract.js';
+import { createObservableState } from './observable-state.js';
+
+/**
+ * The session catalog and the selection, as one external store (#4109).
+ *
+ * They were `useState` inside a hook AppShell calls, which made the shell the
+ * carrier: every catalog commit and every selection change re-rendered the
+ * whole tree, and anything that wanted to follow them — the Session rail above
+ * all — had to be handed them down a prop chain. As a store they have readers
+ * instead of a carrier, and each reader re-renders only for the reading it
+ * selects. Same mechanism as `app-shell-session-ui-state.ts` (#1985); this is
+ * the second store, not a second way of having stores.
+ *
+ * The list and its observation revision are one committed snapshot. A failed
+ * refresh changes neither, so consumers can fence transient writes against
+ * successful catalog observations without a parallel error flag.
+ */
+export interface SessionCatalogState {
+  readonly sessions: readonly DesktopSessionSummary[];
+  readonly revision: number;
+  readonly activeSessionId: string | undefined;
+}
+
+export function createSessionCatalogController() {
+  const state = createObservableState<SessionCatalogState>({
+    sessions: [],
+    revision: 0,
+    activeSessionId: undefined,
+  });
+
+  return {
+    getState: state.getState,
+    subscribe: state.subscribe,
+    commitSessions(next: readonly DesktopSessionSummary[]): void {
+      const current = state.getState();
+      state.replaceState({ ...current, sessions: next, revision: current.revision + 1 });
+    },
+    setActiveSessionId(next: string | undefined): void {
+      const current = state.getState();
+      if (current.activeSessionId === next) return;
+      state.replaceState({ ...current, activeSessionId: next });
+    },
+  };
+}
+
+export type SessionCatalogController = ReturnType<typeof createSessionCatalogController>;
+
+export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] =>
+  state.sessions;
+export const selectCatalogRevision = (state: SessionCatalogState): number => state.revision;
+export const selectActiveSessionId = (state: SessionCatalogState): string | undefined =>
+  state.activeSessionId;
+
+/**
+ * The ids in the catalog, by value. A refresh replaces every row object even
+ * when nothing about the membership moved (#2913), so an identity-only
+ * selection would re-render every reader that only cares about which sessions
+ * exist.
+ */
+export const selectAuthoritativeSessionIds = (state: SessionCatalogState): ReadonlySet<string> =>
+  new Set(state.sessions.map(({ id }) => id));
+
+/**
+ * Owns the controller for the component's lifetime. Deliberately does NOT
+ * subscribe: readers select what they need through `useExternalStoreSelector`.
+ */
+export function useSessionCatalogController(): SessionCatalogController {
+  const controllerRef = useRef<SessionCatalogController | null>(null);
+  if (!controllerRef.current) controllerRef.current = createSessionCatalogController();
+  return controllerRef.current;
+}
diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts
index d1c4695f5..17e423ee3 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-list.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { useMemo, useRef, useState } from 'react';
+import { useCallback, useRef } from 'react';
 import { type LiveTurnProjection, useUiLocale } from '@maka/ui';
 import { getDesktopConversationCopy } from './locales/conversation-copy.js';
 import { localizedShellErrorMessage } from './locales/shell-copy.js';
@@ -29,6 +29,14 @@ import {
   type SessionListRefresher,
 } from './session-read-state.js';
 import { reconcileSettledSessionTransients } from './settled-session-transients.js';
+import {
+  selectAuthoritativeSessionIds,
+  selectCatalogRevision,
+  selectSessions,
+  type SessionCatalogController,
+} from './session-catalog-state.js';
+import { sessionIdSetsEqual } from './live-turn-snapshot.js';
+import { useExternalStoreSelector } from './use-external-store-selector.js';
 import type { DesktopSessionSummary } from '../preload/bridge-contract.js';
 
 type ToastApi = {
@@ -40,6 +48,7 @@ type RefBox<T> = { current: T };
 export function useAppShellSessionList(
   toastApi: ToastApi,
   options: {
+    catalog: SessionCatalogController;
     activeIdRef: RefBox<string | undefined>;
     liveTurnBySessionRef: RefBox<Record<string, LiveTurnProjection>>;
     clearTurnTransientStateIfCurrent: (
@@ -51,24 +60,23 @@ export function useAppShellSessionList(
   const uiLocale = useUiLocale();
   const uiLocaleRef = useRef(uiLocale);
   uiLocaleRef.current = uiLocale;
-  // The list and its observation revision are one committed snapshot. A
-  // failed refresh changes neither, so consumers can fence transient writes
-  // against successful catalog observations without a parallel error flag.
-  const [catalog, setCatalog] = useState<{
-    sessions: DesktopSessionSummary[];
-    revision: number;
-  }>({ sessions: [], revision: 0 });
-  const { sessions, revision: catalogRevision } = catalog;
-  const authoritativeSessionIds = useMemo(
-    () => new Set(sessions.map(({ id }) => id)),
-    [sessions],
+  const { catalog } = options;
+  // Selected from the catalog store rather than held here: the rail follows the
+  // same authority without the shell carrying it down a prop chain (#4109).
+  const sessions = useExternalStoreSelector(catalog, selectSessions);
+  const catalogRevision = useExternalStoreSelector(catalog, selectCatalogRevision);
+  const authoritativeSessionIds = useExternalStoreSelector(
+    catalog,
+    selectAuthoritativeSessionIds,
+    undefined,
+    sessionIdSetsEqual,
   );
   const sessionsRef = useRef<DesktopSessionSummary[]>([]);
   const refresherRef = useRef<SessionListRefresher<DesktopSessionSummary> | null>(null);
 
   function commitSessions(next: DesktopSessionSummary[]): void {
     sessionsRef.current = next;
-    setCatalog((current) => ({ sessions: next, revision: current.revision + 1 }));
+    catalog.commitSessions(next);
   }
 
   if (!refresherRef.current) {
diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
index aa2e24b12..654a867d2 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts
@@ -30,7 +30,7 @@ import {
   sessionIdSetsEqual,
   type LiveTurnSnapshot,
 } from './live-turn-snapshot.js';
-import { useAppShellSessionUiSelector } from './use-app-shell-session-ui-selector.js';
+import { useExternalStoreSelector } from './use-external-store-selector.js';
 
 const selectMessageLoadError = (state: AppShellSessionUiState) => state.messageLoadErrorBySession;
 const selectMessageRetryPending = (state: AppShellSessionUiState) => state.messageRetryPendingBySession;
@@ -83,15 +83,15 @@ export function useAppShellSessionUiReads(
   activeLiveTurnSnapshot: LiveTurnSnapshot;
 } {
   return {
-    messageLoadErrorBySession: useAppShellSessionUiSelector(controller, selectMessageLoadError),
-    messageRetryPendingBySession: useAppShellSessionUiSelector(controller, selectMessageRetryPending),
-    stopPendingBySession: useAppShellSessionUiSelector(controller, selectStopPending),
-    interactionBySession: useAppShellSessionUiSelector(controller, selectInteraction),
-    messageQueueBySession: useAppShellSessionUiSelector(controller, selectMessageQueue),
-    pendingPermissionModeBySession: useAppShellSessionUiSelector(controller, selectPendingPermissionMode),
-    pendingSessionModelBySession: useAppShellSessionUiSelector(controller, selectPendingSessionModel),
-    streamingSessionIds: useAppShellSessionUiSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual),
-    activeLiveTurnSnapshot: useAppShellSessionUiSelector(
+    messageLoadErrorBySession: useExternalStoreSelector(controller, selectMessageLoadError),
+    messageRetryPendingBySession: useExternalStoreSelector(controller, selectMessageRetryPending),
+    stopPendingBySession: useExternalStoreSelector(controller, selectStopPending),
+    interactionBySession: useExternalStoreSelector(controller, selectInteraction),
+    messageQueueBySession: useExternalStoreSelector(controller, selectMessageQueue),
+    pendingPermissionModeBySession: useExternalStoreSelector(controller, selectPendingPermissionMode),
+    pendingSessionModelBySession: useExternalStoreSelector(controller, selectPendingSessionModel),
+    streamingSessionIds: useExternalStoreSelector(controller, selectPulseSet, undefined, sessionIdSetsEqual),
+    activeLiveTurnSnapshot: useExternalStoreSelector(
       controller,
       selectActiveSnapshot,
       activeId,
diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
index f8ccc337b..3f7c4c683 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
+++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts
@@ -21,6 +21,11 @@ import { useRef, useState } from 'react';
 import type { StoredMessage } from '@maka/core/session';
 import type { TransientUserMessageProjection } from '@maka/ui';
 import { useAppShellSessionUiState } from './app-shell-session-ui-state.js';
+import {
+  selectActiveSessionId,
+  useSessionCatalogController,
+} from './session-catalog-state.js';
+import { useExternalStoreSelector } from './use-external-store-selector.js';
 import { useAppShellSessionList } from './use-app-shell-session-list.js';
 import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js';
 import { hasNewTaskReloadIntent } from './new-task-reload-intent.js';
@@ -37,10 +42,15 @@ type ToastApi = {
 type TransientUserMessage = TransientUserMessageProjection;
 
 export function useAppShellSessionWorkspace(toastApi: ToastApi) {
-  const [activeId, setActiveIdState] = useState<string | undefined>();
+  // The catalog and the selection are one authority, and it is a store: the
+  // Session rail subscribes to it directly instead of receiving it from the
+  // shell's render (#4109).
+  const catalog = useSessionCatalogController();
+  const activeId = useExternalStoreSelector(catalog, selectActiveSessionId);
   const activeIdRef = useRef<string | undefined>(undefined);
   const sessionUiController = useAppShellSessionUiState();
   const sessionList = useAppShellSessionList(toastApi, {
+    catalog,
     activeIdRef,
     liveTurnBySessionRef: sessionUiController.liveTurnBySessionRef,
     clearTurnTransientStateIfCurrent: sessionUiController.clearTurnTransientStateIfCurrent,
@@ -67,7 +77,11 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) {
     transientMessagesBySessionRef,
     transcriptRangeRef,
     selectionRevisionRef,
-    setActiveIdState,
+    // The store is the authority for the selection, so the factory's single
+    // write point goes to it rather than to a `useState` beside it. The
+    // controller is created once per renderer, so this identity is fixed and
+    // the once-created factory may capture it.
+    setActiveIdState: catalog.setActiveSessionId,
     setMessagesState: setMessages,
     setTransientMessagesState: setTransientMessages,
     setMessageLoadPending,
@@ -86,6 +100,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) {
 
   return {
     ...sessionList,
+    sessionCatalogController: catalog,
     activeId,
     activeIdRef,
     bootstrapSelectionLease: bootstrapSelectionLeaseRef.current,
diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-selector.ts b/apps/desktop/src/renderer/use-external-store-selector.ts
similarity index 71%
rename from apps/desktop/src/renderer/use-app-shell-session-ui-selector.ts
rename to apps/desktop/src/renderer/use-external-store-selector.ts
index 86f904fdc..dc976d1de 100644
--- a/apps/desktop/src/renderer/use-app-shell-session-ui-selector.ts
+++ b/apps/desktop/src/renderer/use-external-store-selector.ts
@@ -18,15 +18,20 @@
  */
 
 import { useMemo, useSyncExternalStore } from 'react';
-import type { AppShellSessionUiState, AppShellSessionUiStateController } from './app-shell-session-ui-state.js';
+
+/** The reading half of a renderer store: `app-shell-session-ui-state`, `session-catalog-state`. */
+export interface ExternalStore<S> {
+  getState(): S;
+  subscribe(listener: () => void): () => void;
+}
 
 /**
- * Subscribe to one derived reading of session UI state (#1985).
+ * Subscribe to one derived reading of a renderer store (#1985, #4109).
  *
- * The controller is a single external store whose maps change at very different
- * rates — `liveTurnBySession` moves once per streamed token, the rest at human
- * speed. A component re-renders only when the value IT selects changes, so the
- * chat transcript can follow every delta while the shell around it stays still.
+ * A store's parts change at very different rates — `liveTurnBySession` moves
+ * once per streamed token, the session catalog at human speed. A component
+ * re-renders only when the value IT selects changes, so the chat transcript can
+ * follow every delta while the shell around it stays still.
  *
  * `select` must be a stable (module-level) function, and whatever it varies by
  * — a session id, say — is passed as `arg` rather than captured. That is what
@@ -43,16 +48,16 @@ import type { AppShellSessionUiState, AppShellSessionUiStateController } from '.
  * fewer-renders optimization: it carries a value's identity ACROSS a state the
  * selection did not actually change.
  */
-export function useAppShellSessionUiSelector<T, A = undefined>(
-  controller: AppShellSessionUiStateController,
-  select: (state: AppShellSessionUiState, arg: A) => T,
+export function useExternalStoreSelector<S, T, A = undefined>(
+  store: ExternalStore<S>,
+  select: (state: S, arg: A) => T,
   arg?: A,
   isEqual?: (a: T, b: T) => boolean,
 ): T {
   const getSnapshot = useMemo(() => {
-    let cache: { state: AppShellSessionUiState; value: T } | null = null;
+    let cache: { state: S; value: T } | null = null;
     return (): T => {
-      const state = controller.getState();
+      const state = store.getState();
       if (cache && cache.state === state) return cache.value;
       const next = select(state, arg as A);
       const value = cache && (Object.is(cache.value, next) || isEqual?.(cache.value, next) === true)
@@ -61,7 +66,7 @@ export function useAppShellSessionUiSelector<T, A = undefined>(
       cache = { state, value };
       return value;
     };
-  }, [controller, select, arg, isEqual]);
+  }, [store, select, arg, isEqual]);
 
-  return useSyncExternalStore(controller.subscribe, getSnapshot, getSnapshot);
+  return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
 }
diff --git a/apps/desktop/src/renderer/use-project-context.ts b/apps/desktop/src/renderer/use-project-context.ts
index 585c798da..8b6759822 100644
--- a/apps/desktop/src/renderer/use-project-context.ts
+++ b/apps/desktop/src/renderer/use-project-context.ts
@@ -18,6 +18,7 @@
  */
 
 import { useEffect, useRef, useState } from 'react';
+import { useStableActions } from './use-stable-actions.js';
 import type { ProjectRecord } from '@maka/core/project';
 import type { UiLocale } from '@maka/core/ui-locale';
 import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
@@ -251,7 +252,11 @@ export function useAppShellProjectContext(options: {
         (project) =>
           project.id === currentProjectId || project.aliases?.includes(currentProjectId ?? ''),
       );
-  const actions = createAppShellProjectActions({
+  // Stable identities, because the rail's Project rows are built from these:
+  // rebuilt per render they put the whole list back on every AppShell commit
+  // (#4109). Same facade the shell's other action factories already use
+  // (#1043); this one was the last bare call site.
+  const actions = useStableActions(createAppShellProjectActions, {
     uiLocale,
     projectPickerPendingRef,
     projectPickerRequestRef,
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index f68438570..3341c59da 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -27,10 +27,10 @@ import {
   ChatView,
   Composer,
   deriveTitlebarProjectName,
-  SessionListPanel,
   TitlebarSessionIdentity,
 } from '@maka/ui';
 import type { ChatModelChoice, SessionViewMode, TurnViewModel } from '@maka/ui';
+import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js';
 import { AppShellTopbarActions } from '../src/renderer/app-shell-chrome-actions';
 import { WorkbarTitlebarActions } from '../src/renderer/features/workbar';
 import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel';
@@ -60,7 +60,7 @@ export default meta;
 type Story = StoryObj<typeof meta>;
 type ChatViewProps = ComponentProps<typeof ChatView>;
 type ComposerProps = ComponentProps<typeof Composer>;
-type SessionListPanelProps = ComponentProps<typeof SessionListPanel>;
+type SessionListPanelProps = SessionRailStoryProps;
 type SessionGroup = NonNullable<SessionListPanelProps['groups']>[number];
 
 const noop = () => undefined;
@@ -422,7 +422,7 @@ function ComposedShell(props: {
         contentPadding={0}
         mobileNav={{ breakpoint: 'none', hasToggle: false }}
         sideNav={
-          <SessionListPanel
+          <SessionRail
             collapsed={collapsed}
             onCollapsedChange={setCollapsed}
             width={sidebarWidth}
diff --git a/apps/desktop/stories/subagent-sessions.stories.tsx b/apps/desktop/stories/subagent-sessions.stories.tsx
index 554bf57a6..5c6009c7f 100644
--- a/apps/desktop/stories/subagent-sessions.stories.tsx
+++ b/apps/desktop/stories/subagent-sessions.stories.tsx
@@ -47,9 +47,9 @@ import { Text } from '@astryxdesign/core/Text';
 import type { SessionSummary } from '@maka/core/session';
 import type { ToolResultContent } from '@maka/core/events';
 import {
-  SessionListPanel,
   TitlebarSessionIdentity,
 } from '@maka/ui';
+import { SessionRail } from '../../../packages/ui/stories/session-rail-harness.js';
 import { ToolTrow } from '../../../packages/ui/src/tool-activity.js';
 import type { ToolActivityItem } from '../../../packages/ui/src/materialize.js';
 import { deriveSessionRail } from '../src/renderer/features/session-navigation/testing';
@@ -375,7 +375,7 @@ function ProductRail(props: { activeSessionId: string }) {
   );
   return (
     <div style={shell.rail}>
-      <SessionListPanel
+      <SessionRail
         selection={{ section: 'sessions' }}
         sessions={sessions}
         activeId={activeRowId}
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 8be11cb33..326a2af32 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
 
 Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
 
-**Totals:** 219 files — blocker 0, polish 1, aligned 218.
+**Totals:** 221 files — blocker 0, polish 1, aligned 220.
 
 ## Exclusions (explicit)
 
@@ -36,6 +36,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/chat-composer-region.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned |
 | `apps/desktop/src/renderer/chat-message-surface.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned |
 | `apps/desktop/src/renderer/command-palette.tsx` | dialog-overlay | EmptyState | aligned — uses Astryx (EmptyState) | aligned |
+| `apps/desktop/src/renderer/composer-mentions.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/custom-pet-companion.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/error-boundary.tsx` | other | Button, Card | aligned — uses Astryx (Button, Card) | aligned |
 | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -44,7 +45,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/features/module-hub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -229,6 +230,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, IconButton, LayoutHeader, Text, Token, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, IconButton, LayoutHeader, Text, Token, Tooltip) | aligned |
 | `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, VStack | aligned — uses Astryx (Badge, VStack) | aligned |
 | `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav) | aligned |
+| `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, TextInput) | aligned |
 | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | IconButton, Tooltip | aligned — uses Astryx (IconButton, Tooltip) | aligned |
 | `packages/ui/src/skill-inspector.tsx` | shell-chrome-or-panel | Button, Divider, HStack, Heading, MetadataList, MetadataListItem, Switch, Text, VStack | aligned — uses Astryx (Button, Divider, HStack, Heading, MetadataList, MetadataListItem, Switch, Text) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 67daab153..f7dbe7e93 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -8,6 +8,7 @@ apps/desktop/src/renderer/cascade-layers.css
 apps/desktop/src/renderer/chat-composer-region.tsx
 apps/desktop/src/renderer/chat-message-surface.tsx
 apps/desktop/src/renderer/command-palette.tsx
+apps/desktop/src/renderer/composer-mentions.tsx
 apps/desktop/src/renderer/custom-pet-companion.tsx
 apps/desktop/src/renderer/error-boundary.tsx
 apps/desktop/src/renderer/features/goals/services-context.tsx
@@ -16,7 +17,7 @@ apps/desktop/src/renderer/features/goals/ui/goal-host.tsx
 apps/desktop/src/renderer/features/module-hub/services-context.tsx
 apps/desktop/src/renderer/features/module-hub/ui/module-hub-host.tsx
 apps/desktop/src/renderer/features/session-navigation/services-context.tsx
-apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-host.tsx
+apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
 apps/desktop/src/renderer/features/task-entry/services-context.tsx
 apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx
 apps/desktop/src/renderer/features/workbar/services-context.tsx
@@ -201,6 +202,7 @@ packages/ui/src/search-modal.tsx
 packages/ui/src/session-context-layer.tsx
 packages/ui/src/session-history-list.tsx
 packages/ui/src/session-list-panel.tsx
+packages/ui/src/session-rail-context.tsx
 packages/ui/src/session-rename-dialog.tsx
 packages/ui/src/session-sidebar-nav.tsx
 packages/ui/src/skill-inspector.tsx
diff --git a/package.json b/package.json
index 4d37afc8c..7e3eac847 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
     "release:cli:pack": "node scripts/release-cli-package.mjs",
     "release:cli:smoke": "node scripts/smoke-release-cli-package.mjs",
     "release:cli:eval": "node scripts/release-cli-eval-package.mjs",
+    "check:app-shell-hooks": "node scripts/check-app-shell-hooks.mjs",
     "check:asf-headers": "node scripts/asf-license-headers.mjs check",
     "write:asf-headers": "node scripts/asf-license-headers.mjs write",
     "release:asf:source": "node scripts/asf-source-release.mjs create",
diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx
index 3597d463a..a901dd713 100644
--- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx
+++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx
@@ -29,6 +29,24 @@ import {
   type ProjectRowActions,
   type SessionRowActions,
 } from '../session-history-list.js';
+import { SessionRailProvider, type SessionRailData } from '../session-rail-context.js';
+
+/**
+ * The list reads its rows from `SessionRailData`, so a case states the reading
+ * it is about and nothing else.
+ */
+function Rail(props: Partial<SessionRailData> & { sessions: readonly SessionSummary[] }) {
+  const data: SessionRailData = {
+    groupVariant: 'conversation',
+    onSelectSession: () => undefined,
+    ...props,
+  };
+  return (
+    <SessionRailProvider data={data}>
+      <SessionHistoryList />
+    </SessionRailProvider>
+  );
+}
 
 const session: SessionSummary = {
   id: 'session-1',
@@ -93,7 +111,7 @@ function assertNoNestedButtons(markup: string): void {
 test('renders session navigation and row actions as sibling controls', () => {
   const markup = renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionHistoryList
+      <Rail
         sessions={[session]}
         onSelectSession={() => undefined}
         rowActions={rowActions}
@@ -113,7 +131,7 @@ test('renders a scan-friendly compact timestamp in the session rail', () => {
   try {
     const markup = renderToStaticMarkup(
       <LocaleProvider locale="en">
-        <SessionHistoryList
+        <Rail
           sessions={[{ ...session, lastMessageAt: now - 46 * 60_000 }]}
           onSelectSession={() => undefined}
         />
@@ -131,7 +149,7 @@ test('renders Runtime Host live runs without requiring renderer-local streaming'
   const hostRunning = { ...session, runningTurnIds: ['turn-live'] };
   const markup = renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionHistoryList
+      <Rail
         sessions={[hostRunning]}
         onSelectSession={() => undefined}
         rowActions={rowActions}
@@ -150,7 +168,7 @@ for (const [status, attentionLabel] of [
     const awaitingUser = { ...session, status, runningTurnIds: ['turn-live'] };
     const markup = renderToStaticMarkup(
       <LocaleProvider locale="en">
-        <SessionHistoryList
+        <Rail
           sessions={[awaitingUser]}
           streamingSessionIds={new Set([awaitingUser.id])}
           onSelectSession={() => undefined}
@@ -168,7 +186,7 @@ test('keeps known-empty idle unless renderer-local streaming is newer', () => {
   const knownEmpty = { ...session, status: 'running' as const, runningTurnIds: [] as string[] };
   const idleMarkup = renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionHistoryList
+      <Rail
         sessions={[knownEmpty]}
         onSelectSession={() => undefined}
         rowActions={rowActions}
@@ -177,7 +195,7 @@ test('keeps known-empty idle unless renderer-local streaming is newer', () => {
   );
   const locallyStreamingMarkup = renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionHistoryList
+      <Rail
         sessions={[knownEmpty]}
         streamingSessionIds={new Set([knownEmpty.id])}
         onSelectSession={() => undefined}
@@ -194,7 +212,7 @@ test('keeps known-empty idle unless renderer-local streaming is newer', () => {
 test('renders collapsible project navigation and row actions as sibling controls', () => {
   const markup = renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionHistoryList
+      <Rail
         sessions={[session]}
         groups={[{ id: project.id, label: project.name, project, sessions: [session] }]}
         groupVariant="project"
diff --git a/packages/ui/src/__tests__/sidebar-footer.test.tsx b/packages/ui/src/__tests__/sidebar-footer.test.tsx
index 89fcb0038..fb8cba1bb 100644
--- a/packages/ui/src/__tests__/sidebar-footer.test.tsx
+++ b/packages/ui/src/__tests__/sidebar-footer.test.tsx
@@ -27,18 +27,32 @@ import assert from 'node:assert/strict';
 import test from 'node:test';
 import { renderToStaticMarkup } from 'react-dom/server';
 import { LocaleProvider } from '../locale-context.js';
+import { SessionRailProvider, type SessionRailChrome } from '../session-rail-context.js';
 import { SessionSidebarFooter } from '../session-sidebar-nav.js';
 
 function renderFooter(
   updateReminder?: { state: 'downloaded' | 'error'; latestVersion: string },
 ): string {
+  const chrome: SessionRailChrome = {
+    collapsed: false,
+    onCollapsedChange: () => undefined,
+    width: 260,
+    onWidthChange: () => undefined,
+    minWidth: 180,
+    maxWidth: 480,
+    viewMode: 'conversation',
+    selection: { section: 'sessions' },
+    onSelect: () => undefined,
+    onNew: () => undefined,
+    onOpenSettings: () => undefined,
+    onOpenUpdate: () => undefined,
+    updateReminder,
+  };
   return renderToStaticMarkup(
     <LocaleProvider locale="en">
-      <SessionSidebarFooter
-        updateReminder={updateReminder}
-        onOpenSettings={() => undefined}
-        onOpenUpdate={() => undefined}
-      />
+      <SessionRailProvider data={{ sessions: [], groupVariant: 'conversation', onSelectSession: () => undefined }} chrome={chrome}>
+        <SessionSidebarFooter />
+      </SessionRailProvider>
     </LocaleProvider>,
   );
 }
diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx
index b981b13e1..bfde2290b 100644
--- a/packages/ui/src/components.tsx
+++ b/packages/ui/src/components.tsx
@@ -28,7 +28,12 @@ export { ModuleHubSelector } from './module-hub-selector.js';
 export type { ModuleHubHeader } from './module-hub-selector.js';
 export { SearchModal } from './search-modal.js';
 export { SessionListPanel } from './session-list-panel.js';
-export type { SessionViewMode } from './session-list-panel.js';
+export { SessionRailProvider } from './session-rail-context.js';
+export type {
+  SessionRailChrome,
+  SessionRailData,
+  SessionViewMode,
+} from './session-rail-context.js';
 export type { SidebarUpdateReminder } from './session-sidebar-nav.js';
 export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js';
 export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 247863794..cfa924716 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -25,7 +25,11 @@ export * from './use-mounted-ref.js';
 export * from './components.js';
 export type { ComposerProps } from './components.js';
 export type { SandboxBoundaryPromptProps } from './sandbox-boundary-prompt.js';
-export type { SessionHistoryGroup } from './session-history-list.js';
+export type {
+  ProjectRowActions,
+  SessionHistoryGroup,
+  SessionRowActions,
+} from './session-history-list.js';
 export * from './session-status-presentation.js';
 export * from './composer-helpers.js';
 export * from './conversation-copy.js';
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index dccdd1cbb..efe2af78a 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -56,6 +56,7 @@ import { StatusDot, type StatusDotVariant } from '@astryxdesign/core/StatusDot';
 import { describeBlockedReason, presentSessionStatus } from './session-status-presentation.js';
 import { dotForStatus } from './status-vocabulary.js';
 import { SessionRenameDialog, type SessionRenameTarget } from './session-rename-dialog.js';
+import { useSessionRailData } from './session-rail-context.js';
 import { useUiLocale } from './locale-context.js';
 import { getConversationCopy } from './conversation-copy.js';
 
@@ -86,19 +87,8 @@ export interface SessionHistoryGroup {
   project?: ProjectRecord;
 }
 
-export function SessionHistoryList(props: {
-  sessions: SessionSummary[];
-  activeId?: string;
-  streamingSessionIds?: Set<string>;
-  staleSessionIds?: Set<string>;
-  groups?: ReadonlyArray<SessionHistoryGroup>;
-  worktreeSessionIds?: ReadonlySet<string>;
-  sessionMeta?(session: SessionSummary): string | undefined;
-  projectActions?: ProjectRowActions;
-  groupVariant?: SessionHistoryGroupVariant;
-  onSelectSession(sessionId: string): void;
-  rowActions?: SessionRowActions;
-}) {
+export function SessionHistoryList() {
+  const rail = useSessionRailData();
   const locale = useUiLocale();
 
   function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
@@ -111,9 +101,9 @@ export function SessionHistoryList(props: {
     const sessionId =
       row?.dataset.sessionId ??
       row?.querySelector<HTMLElement>('[data-session-id]')?.dataset.sessionId;
-    if (sessionId && props.rowActions) {
+    if (sessionId && rail.rowActions) {
       event.preventDefault();
-      void props.rowActions.onDelete(sessionId);
+      void rail.rowActions.onDelete(sessionId);
     }
   }
 
@@ -126,28 +116,19 @@ export function SessionHistoryList(props: {
     <div className="maka-session-list" onKeyDown={handleListKeyDown}>
       <SessionListGroups
         groups={
-          props.groups
-            ? props.groups.map((g) => ({
+          rail.groups
+            ? rail.groups.map((g) => ({
                 key: g.id,
                 label: g.label,
                 sessions: g.sessions,
                 project: g.project,
               }))
-            : groupSessionsForHistory(props.sessions, locale).map((g) => ({
+            : groupSessionsForHistory(rail.sessions, locale).map((g) => ({
                 key: g.id,
                 label: g.label,
                 sessions: g.sessions,
               }))
         }
-        groupVariant={props.groupVariant ?? 'conversation'}
-        activeId={props.activeId}
-        streamingSessionIds={props.streamingSessionIds}
-        staleSessionIds={props.staleSessionIds}
-        worktreeSessionIds={props.worktreeSessionIds}
-        sessionMeta={props.sessionMeta}
-        onSelectSession={props.onSelectSession}
-        rowActions={props.rowActions}
-        projectActions={props.projectActions}
       />
     </div>
   );
@@ -160,16 +141,8 @@ function SessionListGroups(props: {
     sessions: SessionSummary[];
     project?: ProjectRecord;
   }>;
-  groupVariant: SessionHistoryGroupVariant;
-  activeId?: string;
-  streamingSessionIds?: Set<string>;
-  staleSessionIds?: Set<string>;
-  worktreeSessionIds?: ReadonlySet<string>;
-  sessionMeta?(session: SessionSummary): string | undefined;
-  onSelectSession(sessionId: string): void;
-  rowActions?: SessionRowActions;
-  projectActions?: ProjectRowActions;
 }) {
+  const rail = useSessionRailData();
   const copy = getConversationCopy(useUiLocale()).sessions;
   const [renameTarget, setRenameTarget] = useState<SessionRenameTarget | null>(null);
   /**
@@ -208,7 +181,7 @@ function SessionListGroups(props: {
 
   function commitRename(target: SessionRenameTarget, name: string) {
     const rename =
-      target.kind === 'project' ? props.projectActions?.onRename : props.rowActions?.onRename;
+      target.kind === 'project' ? rail.projectActions?.onRename : rail.rowActions?.onRename;
     if (!rename) return;
     void Promise.resolve(rename(target.id, name)).catch(() => {
       // AppShell owns visible rename failure feedback.
@@ -217,31 +190,27 @@ function SessionListGroups(props: {
 
   // Linked subagent sessions open in the main chat column, not as nested
   // sidebar rows. The host passes only root/user sessions here.
+  //
+  // Two dependencies, not eight. The eight were one value — everything a row is
+  // drawn from — spelled out because it arrived as eight separate props, and
+  // any one of them changing identity upstream rebuilt every row. It arrives as
+  // `rail` now, so this array says what it always meant (#4109).
   const renderSessionRow = useCallback(
     (session: SessionSummary): ReactNode => (
       <SessionNavRow
         key={session.id}
         session={session}
-        active={session.id === props.activeId}
-        streaming={props.streamingSessionIds?.has(session.id) ?? false}
-        stale={props.staleSessionIds?.has(session.id) ?? false}
-        worktree={props.worktreeSessionIds?.has(session.id) ?? false}
-        meta={props.sessionMeta?.(session)}
-        onSelectSession={props.onSelectSession}
-        actions={props.rowActions}
+        active={session.id === rail.activeId}
+        streaming={rail.streamingSessionIds?.has(session.id) ?? false}
+        stale={rail.staleSessionIds?.has(session.id) ?? false}
+        worktree={rail.worktreeSessionIds?.has(session.id) ?? false}
+        meta={rail.sessionMeta?.(session)}
+        onSelectSession={rail.onSelectSession}
+        actions={rail.rowActions}
         onStartRename={startRename}
       />
     ),
-    [
-      startRename,
-      props.activeId,
-      props.onSelectSession,
-      props.rowActions,
-      props.sessionMeta,
-      props.staleSessionIds,
-      props.streamingSessionIds,
-      props.worktreeSessionIds,
-    ],
+    [rail, startRename],
   );
 
   // Keyed per target so the field seeds from the name that row carries now,
@@ -257,7 +226,7 @@ function SessionListGroups(props: {
     />
   ) : null;
 
-  if (props.groupVariant === 'project') {
+  if (rail.groupVariant === 'project') {
     const activeGroups = props.groups.filter((group) => group.project?.archivedAt === undefined);
     const archivedGroups = props.groups.filter((group) => group.project?.archivedAt !== undefined);
 
@@ -272,7 +241,7 @@ function SessionListGroups(props: {
           label={group.label}
           project={project}
           sessions={group.sessions}
-          projectActions={props.projectActions}
+          projectActions={rail.projectActions}
           onStartRename={(opener) => {
             if (project) {
               startRename({ kind: 'project', id: project.id, name: project.name }, opener);
@@ -833,7 +802,10 @@ interface SessionGroup {
   sessions: SessionSummary[];
 }
 
-function groupSessionsForHistory(sessions: SessionSummary[], locale: UiLocale): SessionGroup[] {
+function groupSessionsForHistory(
+  sessions: readonly SessionSummary[],
+  locale: UiLocale,
+): SessionGroup[] {
   const copy = getConversationCopy(locale).sessions;
   const ordered = [...sessions].sort((a, b) => {
     const timestampDelta = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0);
diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx
index 72c283a1c..1f7522162 100644
--- a/packages/ui/src/session-list-panel.tsx
+++ b/packages/ui/src/session-list-panel.tsx
@@ -17,83 +17,38 @@
  * under the License.
  */
 
-import type { ScheduledTask } from '@maka/core/scheduled-task';
-import type { SessionSummary } from '@maka/core/session';
 import {
   SegmentedControl,
   SegmentedControlItem,
 } from '@astryxdesign/core/SegmentedControl';
-import { SideNav, type SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav';
-import type { NavModuleMemory, NavSelection } from './nav-selection.js';
+import { SideNav } from '@astryxdesign/core/SideNav';
+import { SessionHistoryList } from './session-history-list.js';
 import {
-  SessionHistoryList,
-  type ProjectRowActions,
-  type SessionHistoryGroup,
-  type SessionRowActions,
-} from './session-history-list.js';
+  useSessionRailChrome,
+  type SessionViewMode,
+} from './session-rail-context.js';
 import {
   SessionSidebarFooter,
   SessionSidebarNav,
-  type SidebarUpdateReminder,
 } from './session-sidebar-nav.js';
 import { useUiLocale } from './locale-context.js';
 import { getConversationCopy } from './conversation-copy.js';
-import type { Ref } from 'react';
 
-export type SessionViewMode = 'conversation' | 'project';
+/**
+ * One element, created once, for the ~1,000 fibers below it.
+ *
+ * The list takes no props — it reads `SessionRailData` — so this element never
+ * needs rebuilding, and React skips the whole subtree whenever the panel around
+ * it re-renders for chrome that changed. That is the boundary; there is no
+ * `memo` comparator, because there is nothing to compare (#4109).
+ */
+const SESSION_HISTORY_LIST = <SessionHistoryList />;
 
-export function SessionListPanel(props: {
-  collapsed?: boolean;
-  onCollapsedChange?(collapsed: boolean): void;
-  /* The rail's collapse is two pieces of state, not one: the boolean this shell
-     owns, and the width Astryx's `useResizable` keeps behind `resizable`.
-     Dragging the handle past Astryx's threshold zeroes that width and reports
-     the collapse outward, so a toggle that only flips the boolean back leaves
-     the rail expanded over a stored width of 0 — the next drag starts from
-     zero. `toggle()` is the one call that moves both. */
-  collapseHandleRef?: Ref<SideNavImperativeCollapseHandle>;
-  width?: number;
-  onWidthChange?(width: number): void;
-  minWidth?: number;
-  maxWidth?: number;
-  selection: NavSelection;
-  sessions: SessionSummary[];
-  activeId?: string;
-  scheduledTasks?: readonly ScheduledTask[];
-  streamingSessionIds?: Set<string>;
-  staleSessionIds?: Set<string>;
-  groups?: ReadonlyArray<SessionHistoryGroup>;
-  worktreeSessionIds?: ReadonlySet<string>;
-  sessionMeta?(session: SessionSummary): string | undefined;
-  projectActions?: ProjectRowActions;
-  viewMode?: SessionViewMode;
-  onViewModeChange?: (mode: SessionViewMode) => void;
-  onSelectSession(sessionId: string): void;
-  moduleMemory?: NavModuleMemory;
-  onSelect(selection: NavSelection): void;
-  onOpenSettings(): void;
-  updateReminder?: SidebarUpdateReminder;
-  onOpenUpdate?(): void;
-  onNew(): void;
-  workHubEntry?: {
-    active: boolean;
-    label: string;
-    onSelect(): void;
-  };
-  rowActions?: SessionRowActions;
-}) {
+/** The Session rail: permanent chrome around the history list. */
+export function SessionListPanel() {
   const copy = getConversationCopy(useUiLocale()).sessions;
-  const {
-    collapsed = false,
-    onCollapsedChange = () => {},
-    width = 260,
-    onWidthChange = () => {},
-    minWidth = 180,
-    maxWidth = 480,
-    viewMode = 'conversation',
-    onViewModeChange,
-    groups,
-  } = props;
+  const chrome = useSessionRailChrome();
+  const { collapsed, viewMode, onViewModeChange } = chrome;
 
   // A view switch, not a command: two exclusive ways to read the same list.
   // Astryx spends a SegmentedControl on exactly this — see its own file-explorer
@@ -147,19 +102,19 @@ export function SessionListPanel(props: {
     // plate instead of straddling the seam between the two.
     <div className="maka-sidenav-motion">
       <SideNav
-        handleRef={props.collapseHandleRef}
+        handleRef={chrome.collapseHandleRef}
         className="maka-session-panel agents-sidebar"
         aria-label={copy.listAriaLabel}
         collapsible={{
           isCollapsed: collapsed,
-          onCollapsedChange,
+          onCollapsedChange: chrome.onCollapsedChange,
           hasButton: false,
         }}
         resizable={{
-          defaultWidth: width,
-          minWidth,
-          maxWidth,
-          onWidthChange,
+          defaultWidth: chrome.width,
+          minWidth: chrome.minWidth,
+          maxWidth: chrome.maxWidth,
+          onWidthChange: chrome.onWidthChange,
         }}
         // Permanent chrome stays sticky via SideNav topContent; only history
         // scrolls in children (Astryx five-zone model). The section inside owns
@@ -167,40 +122,13 @@ export function SessionListPanel(props: {
         // already names the panel on screen, and stays for assistive tech.
         topContent={
           <>
-            <SessionSidebarNav
-              selection={props.selection}
-              scheduledTasks={props.scheduledTasks}
-              moduleMemory={props.moduleMemory}
-              onSelect={props.onSelect}
-              onNew={props.onNew}
-              workHubEntry={props.workHubEntry}
-            />
+            <SessionSidebarNav />
             {groupingSwitch}
           </>
         }
-        footer={
-          <SessionSidebarFooter
-            updateReminder={props.updateReminder}
-            onOpenSettings={props.onOpenSettings}
-            onOpenUpdate={props.onOpenUpdate}
-          />
-        }
+        footer={<SessionSidebarFooter />}
       >
-        {!collapsed ? (
-          <SessionHistoryList
-            sessions={props.sessions}
-            activeId={props.activeId}
-            streamingSessionIds={props.streamingSessionIds}
-            staleSessionIds={props.staleSessionIds}
-            groupVariant={viewMode}
-            groups={groups}
-            worktreeSessionIds={props.worktreeSessionIds}
-            sessionMeta={props.sessionMeta}
-            projectActions={props.projectActions}
-            onSelectSession={props.onSelectSession}
-            rowActions={props.rowActions}
-          />
-        ) : null}
+        {!collapsed ? SESSION_HISTORY_LIST : null}
       </SideNav>
     </div>
   );
diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx
new file mode 100644
index 000000000..ced9c8e13
--- /dev/null
+++ b/packages/ui/src/session-rail-context.tsx
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createContext, useContext, type ReactNode, type Ref } from 'react';
+import type { ScheduledTask } from '@maka/core/scheduled-task';
+import type { SessionSummary } from '@maka/core/session';
+import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav';
+import type { NavModuleMemory, NavSelection } from './nav-selection.js';
+import type {
+  ProjectRowActions,
+  SessionHistoryGroup,
+  SessionRowActions,
+} from './session-history-list.js';
+import type { SidebarUpdateReminder } from './session-sidebar-nav.js';
+
+export type SessionViewMode = 'conversation' | 'project';
+
+/**
+ * What the rail's rows are made of.
+ *
+ * One declaration, read by the list and by every row under it. It used to be
+ * the same eleven props redeclared at each of `SessionListPanel`,
+ * `SessionHistoryList` and `SessionListGroups`, threaded by hand and kept
+ * identity-stable by hand, because the state lived above the whole shell and
+ * had no other way down (#4109). Read from here it has one producer, so its
+ * identity is the producer's business alone: hold this value still and the
+ * ~1,000 fibers below do not render.
+ */
+export interface SessionRailData {
+  sessions: readonly SessionSummary[];
+  activeId?: string;
+  streamingSessionIds?: ReadonlySet<string>;
+  staleSessionIds?: ReadonlySet<string>;
+  worktreeSessionIds?: ReadonlySet<string>;
+  /** Pre-grouped rows. Absent means group by recency here. */
+  groups?: ReadonlyArray<SessionHistoryGroup>;
+  groupVariant: SessionViewMode;
+  sessionMeta?(session: SessionSummary): string | undefined;
+  onSelectSession(sessionId: string): void;
+  rowActions?: SessionRowActions;
+  projectActions?: ProjectRowActions;
+}
+
+/**
+ * The rail's permanent chrome: the nav above the list, the footer below it, and
+ * the column's own geometry.
+ *
+ * Deliberately a SECOND context rather than more fields on `SessionRailData`.
+ * These follow the shell — which section is selected, whether an update is
+ * waiting — and they change far more often than the list does, while costing a
+ * few dozen fibers against the list's thousand. Splitting them is what lets the
+ * chrome follow the shell without dragging the list with it.
+ */
+export interface SessionRailChrome {
+  collapsed: boolean;
+  onCollapsedChange(collapsed: boolean): void;
+  collapseHandleRef?: Ref<SideNavImperativeCollapseHandle>;
+  width: number;
+  onWidthChange(width: number): void;
+  minWidth: number;
+  maxWidth: number;
+  viewMode: SessionViewMode;
+  onViewModeChange?(mode: SessionViewMode): void;
+  selection: NavSelection;
+  scheduledTasks?: readonly ScheduledTask[];
+  moduleMemory?: NavModuleMemory;
+  onSelect(selection: NavSelection): void;
+  onNew(): void;
+  onOpenSettings(): void;
+  updateReminder?: SidebarUpdateReminder;
+  onOpenUpdate?(): void;
+  workHubEntry?: {
+    active: boolean;
+    label: string;
+    onSelect(): void;
+  };
+}
+
+const SessionRailDataContext = createContext<SessionRailData | null>(null);
+const SessionRailChromeContext = createContext<SessionRailChrome | null>(null);
+
+/**
+ * `chrome` is optional so the list can be rendered on its own — a test or a
+ * story about rows has no permanent chrome to describe, and inventing one would
+ * be describing something it is not asserting.
+ */
+export function SessionRailProvider(props: {
+  data: SessionRailData;
+  chrome?: SessionRailChrome;
+  children?: ReactNode;
+}) {
+  return (
+    <SessionRailDataContext.Provider value={props.data}>
+      <SessionRailChromeContext.Provider value={props.chrome ?? null}>
+        {props.children}
+      </SessionRailChromeContext.Provider>
+    </SessionRailDataContext.Provider>
+  );
+}
+
+export function useSessionRailData(): SessionRailData {
+  const data = useContext(SessionRailDataContext);
+  if (!data) throw new Error('SessionRailProvider is missing');
+  return data;
+}
+
+export function useSessionRailChrome(): SessionRailChrome {
+  const chrome = useContext(SessionRailChromeContext);
+  if (!chrome) throw new Error('SessionRailProvider is missing');
+  return chrome;
+}
diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx
index af9c801ae..368aef33a 100644
--- a/packages/ui/src/session-sidebar-nav.tsx
+++ b/packages/ui/src/session-sidebar-nav.tsx
@@ -17,9 +17,8 @@
  * under the License.
  */
 
-import type { ScheduledTask } from '@maka/core/scheduled-task';
 import { AlertCircle, Blocks, Download, Network, Settings, SquarePen, Timer } from './icons.js';
-import type { NavModuleMemory, NavSelection } from './nav-selection.js';
+import { useSessionRailChrome } from './session-rail-context.js';
 import { useUiLocale } from './locale-context.js';
 import { getShellControlsCopy } from './shell-controls-copy.js';
 import { Icon } from '@astryxdesign/core/Icon';
@@ -27,18 +26,8 @@ import { IconButton } from '@astryxdesign/core/IconButton';
 import { SideNavItem, SideNavSection } from '@astryxdesign/core/SideNav';
 import { Tooltip } from '@astryxdesign/core/Tooltip';
 
-export function SessionSidebarNav(props: {
-  selection: NavSelection;
-  scheduledTasks?: readonly ScheduledTask[];
-  moduleMemory?: NavModuleMemory;
-  onSelect(selection: NavSelection): void;
-  onNew(): void;
-  workHubEntry?: {
-    active: boolean;
-    label: string;
-    onSelect(): void;
-  };
-}) {
+export function SessionSidebarNav() {
+  const props = useSessionRailChrome();
   const locale = useUiLocale();
   const copy = getShellControlsCopy(locale).navigation;
   const extensionsActive = props.selection.section === 'extensions';
@@ -122,11 +111,8 @@ export type SidebarUpdateReminder = {
   latestVersion: string;
 };
 
-export function SessionSidebarFooter(props: {
-  updateReminder?: SidebarUpdateReminder;
-  onOpenSettings(): void;
-  onOpenUpdate?(): void;
-}) {
+export function SessionSidebarFooter() {
+  const props = useSessionRailChrome();
   const locale = useUiLocale();
   const copy = getShellControlsCopy(locale).navigation;
   const reminder = props.updateReminder;
diff --git a/packages/ui/stories/composite-row-states.stories.tsx b/packages/ui/stories/composite-row-states.stories.tsx
index 59ed8c2e5..13128149e 100644
--- a/packages/ui/stories/composite-row-states.stories.tsx
+++ b/packages/ui/stories/composite-row-states.stories.tsx
@@ -20,7 +20,7 @@
 import type { Meta, StoryObj } from '@storybook/react-vite';
 import type { SessionSummary } from '@maka/core/session';
 import { userEvent } from 'storybook/test';
-import { SessionListPanel } from '../src/session-list-panel.js';
+import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.js';
 
 // Compares the resting, hover and focus states of two DIFFERENT row components
 // — Astryx's side-nav item and its list item — as the sidebar composes them.
@@ -91,7 +91,7 @@ export const ListRowStates: Story = {
       description="真实侧栏导航与会话行保留自己的布局、选中态和 focus-within seam。"
     >
       <div style={{ height: 440, overflow: 'hidden', width: 260 }}>
-        <SessionListPanel
+        <SessionRail
           selection={{ section: 'extensions', module: 'skills' }}
           sessions={COMPOSITE_ROW_SESSIONS}
           activeId="interaction-active"
diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx
index e61f51895..78143a561 100644
--- a/packages/ui/stories/session-list-panel.stories.tsx
+++ b/packages/ui/stories/session-list-panel.stories.tsx
@@ -22,7 +22,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
 import { expect, waitFor, within } from 'storybook/test';
 import type { ProjectRecord } from '@maka/core/project';
 import type { SessionBlockedReason, SessionStatus, SessionSummary } from '@maka/core/session';
-import { SessionListPanel } from '../src/session-list-panel.js';
+import { SessionRail, type SessionRailStoryProps } from './session-rail-harness.js';
 
 const NOW = Date.now();
 
@@ -39,7 +39,7 @@ const meta = {
 export default meta;
 
 type Story = StoryObj<typeof meta>;
-type SessionListPanelProps = Parameters<typeof SessionListPanel>[0];
+type SessionListPanelProps = SessionRailStoryProps;
 
 const noop = () => undefined;
 
@@ -279,7 +279,7 @@ const liveRunAuthoritySessions: SessionSummary[] = [
 export const Empty: Story = {
   render: () => (
     <StoryFrame>
-      <SessionListPanel {...panelProps({ sessions: [] })} />
+      <SessionRail {...panelProps({ sessions: [] })} />
     </StoryFrame>
   ),
 };
@@ -289,7 +289,7 @@ export const Empty: Story = {
 export const ConversationStates: Story = {
   render: () => (
     <StoryFrame>
-      <SessionListPanel {...panelProps({
+      <SessionRail {...panelProps({
         sessions: statusSessions,
         activeId: 'status-waiting',
         streamingSessionIds: new Set(['status-running']),
@@ -305,7 +305,7 @@ export const ConversationStates: Story = {
 export const ActiveTaskActionsOpen: Story = {
   render: () => (
     <StoryFrame openSessionMenuId="status-waiting">
-      <SessionListPanel {...panelProps({
+      <SessionRail {...panelProps({
         sessions: statusSessions,
         activeId: 'status-waiting',
       })} />
@@ -324,7 +324,7 @@ export const ActiveTaskActionsOpen: Story = {
 export const LiveRunAuthorityStates: Story = {
   render: () => (
     <StoryFrame>
-      <SessionListPanel {...panelProps({
+      <SessionRail {...panelProps({
         sessions: liveRunAuthoritySessions,
         activeId: 'live-remote-running',
         streamingSessionIds: new Set(['live-local-race']),
@@ -338,7 +338,7 @@ export const LiveRunAuthorityStates: Story = {
 export const LongTitlesAndNarrow: Story = {
   render: () => (
     <StoryFrame width={180}>
-      <SessionListPanel {...panelProps({
+      <SessionRail {...panelProps({
         width: 180,
         sessions: longTitleSessions,
         activeId: 'long-title-active',
@@ -353,7 +353,7 @@ export const LongTitlesAndNarrow: Story = {
 export const PinnedAndRecentSections: Story = {
   render: () => (
     <StoryFrame>
-      <SessionListPanel
+      <SessionRail
         {...panelProps({
           sessions: [
             makeSession({
@@ -431,7 +431,7 @@ export const ProjectGroups: Story = {
     ];
     return (
       <StoryFrame height={720}>
-        <SessionListPanel
+        <SessionRail
           {...panelProps({
             sessions,
             activeId: 'proj-worktree',
diff --git a/packages/ui/stories/session-rail-harness.tsx b/packages/ui/stories/session-rail-harness.tsx
new file mode 100644
index 000000000..b3dc87816
--- /dev/null
+++ b/packages/ui/stories/session-rail-harness.tsx
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SessionListPanel } from '../src/session-list-panel.js';
+import {
+  SessionRailProvider,
+  type SessionRailChrome,
+  type SessionRailData,
+} from '../src/session-rail-context.js';
+
+export type SessionRailStoryProps = Partial<SessionRailData> &
+  Partial<SessionRailChrome> &
+  Pick<SessionRailData, 'sessions'> &
+  Pick<SessionRailChrome, 'selection'>;
+
+/**
+ * The rail, described as one flat bag of state.
+ *
+ * In the app the rail reads two contexts and takes no props, which is what
+ * keeps the shell's renders off it. A story has no shell to be kept off, and
+ * one state per story reads better than two objects, so the split happens here.
+ */
+export function SessionRail(props: SessionRailStoryProps) {
+  const data: SessionRailData = {
+    sessions: props.sessions,
+    activeId: props.activeId,
+    streamingSessionIds: props.streamingSessionIds,
+    staleSessionIds: props.staleSessionIds,
+    worktreeSessionIds: props.worktreeSessionIds,
+    groups: props.groups,
+    groupVariant: props.groupVariant ?? props.viewMode ?? 'conversation',
+    sessionMeta: props.sessionMeta,
+    onSelectSession: props.onSelectSession ?? (() => undefined),
+    rowActions: props.rowActions,
+    projectActions: props.projectActions,
+  };
+  const chrome: SessionRailChrome = {
+    collapsed: props.collapsed ?? false,
+    onCollapsedChange: props.onCollapsedChange ?? (() => undefined),
+    collapseHandleRef: props.collapseHandleRef,
+    width: props.width ?? 260,
+    onWidthChange: props.onWidthChange ?? (() => undefined),
+    minWidth: props.minWidth ?? 180,
+    maxWidth: props.maxWidth ?? 480,
+    viewMode: props.viewMode ?? 'conversation',
+    onViewModeChange: props.onViewModeChange,
+    selection: props.selection,
+    scheduledTasks: props.scheduledTasks,
+    moduleMemory: props.moduleMemory,
+    onSelect: props.onSelect ?? (() => undefined),
+    onNew: props.onNew ?? (() => undefined),
+    onOpenSettings: props.onOpenSettings ?? (() => undefined),
+    updateReminder: props.updateReminder,
+    onOpenUpdate: props.onOpenUpdate,
+    workHubEntry: props.workHubEntry,
+  };
+  return (
+    <SessionRailProvider data={data} chrome={chrome}>
+      <SessionListPanel />
+    </SessionRailProvider>
+  );
+}
diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs
new file mode 100644
index 000000000..0f16b26cc
--- /dev/null
+++ b/scripts/check-app-shell-hooks.mjs
@@ -0,0 +1,447 @@
+#!/usr/bin/env node
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Convergence gate for #4109: which hooks are still called in the render bodies
+ * of the shell components that sit above the whole tree.
+ *
+ * In React the position in the tree IS the scope of the state. A hook called in
+ * the shell's render body has the whole tree as its scope; the same hook called
+ * in a provider has its readers as its scope. `AppShellContent` today carries
+ * 536 hooks above the entire tree, so a session switch produces ~19 commits,
+ * ~14 of them full-tree renders, of which 96-99% is recoverable work.
+ *
+ * The fix is to move call sites, one feature at a time. That is slow, and a
+ * checklist cannot tell whether it is progressing — extracting a feature into
+ * its own slice does not change the scope of any state, and Session Navigation
+ * proves it: a complete `model / controller / ui / ports` slice whose
+ * controller is still invoked from this render body.
+ *
+ * A call site does have a definition of done, so this gate counts them. The
+ * inventory below is EXACT, not a ceiling: growing past an entry fails, and so
+ * does falling below one. Only failing on growth would let the counts drift
+ * quietly downward and record no progress, which is the one thing this gate
+ * exists to make visible. The cost is real — a change that removes an effect
+ * has to edit this file — and that edit IS the record.
+ *
+ * Deliberately NOT a `--write` mode. Regenerating the inventory on demand would
+ * let a new hook be accepted by rerunning a command, and the friction of
+ * editing it by hand is the point. This is the opposite shape from
+ * `check-astryx-surface-inventory.mjs`, whose invariant is "regenerate and
+ * compare bytes, never a hand-written list". That is right for an inventory
+ * that only has to describe the tree; this one has to RESIST it.
+ *
+ * Two shapes of migration, because the failure message cannot tell them apart:
+ * when nothing in the shell body reads the feature, the call site moves into a
+ * provider and React bails out of `children` (the mention catalog, #4109) — the
+ * entry disappears. When the shell body DOES read it, moving the call site
+ * still leaves a selector read behind, and the honest outcome is a smaller
+ * entry rather than none. "Call it from the provider" is not advice to follow
+ * blindly: cross-feature intent the shell issues must stay an explicit command,
+ * never an implicit subscription bought to lower a number.
+ *
+ * The count is unweighted, so it measures migration progress and not render
+ * cost: a subscription that fires five times per session switch and a hook that
+ * runs once at mount both count as one. Falling counts do not by themselves
+ * demonstrate a faster switch.
+ *
+ * Both `AppShell` and `AppShellContent` are counted. `AppShell` wraps the
+ * content in the root providers, so a hook hoisted one level up has if anything
+ * a WIDER scope — an inventory that watched only the inner component would
+ * accept that move as progress.
+ *
+ * The counting is textual, because TypeScript 7 exposes no stable parser to
+ * JavaScript (only `typescript/unstable/*`), and a gate that goes red whenever
+ * a dependency moves is its own kind of noise. Textual counting is therefore
+ * written to fail closed: `stripNonCode` blanks comments, strings and regex
+ * literals in place, the body is delimited by brace balancing over that blanked
+ * text rather than by a `\n}\n` guess, and a hook name is only counted where a
+ * call actually follows it — including through an explicit type argument, which
+ * an earlier version of this gate silently skipped over.
+ *
+ * Purely derived hooks (`useMemo`, `useCallback`, `useRef`, `useId`) are
+ * ignored: they hold no state and subscribe to nothing, so they cannot widen
+ * the scope of a render. Everything else counts, including React's own
+ * `useState` / `useEffect`, because a custom hook is only a name for them.
+ *
+ * Run: npm run check:app-shell-hooks
+ * Fix: move the call site into the feature's provider, then delete its entry.
+ */
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = join(fileURLToPath(new URL('..', import.meta.url)));
+const shellFile = 'apps/desktop/src/renderer/app-shell.tsx';
+
+/** Hooks that hold no state and subscribe to nothing. */
+const DERIVED_HOOKS = new Set(['useMemo', 'useCallback', 'useRef', 'useId']);
+
+/**
+ * Hooks still called in each shell component's render body, with the number of
+ * call sites. This inventory may only shrink. Each entry is one scope that is
+ * still the whole tree rather than its readers.
+ */
+export const ALLOWED = {
+  AppShell: {
+    useState: 2,
+    useSystemUiLocale: 1,
+  },
+  AppShellContent: {
+    useActiveExecutionBoundary: 1,
+    useActiveSessionEvents: 1,
+    useAppShellBootstrapSubscriptions: 1,
+    useAppShellComposerQuotes: 1,
+    useAppShellHostEffects: 1,
+    useAppShellNavRefSync: 1,
+    useAppShellPersistenceEffects: 1,
+    useAppShellProjectContext: 1,
+    useAppShellSessionUiReads: 1,
+    useAppShellSessionWorkspace: 1,
+    useAppShellTurnPresentation: 1,
+    useCommandPalette: 1,
+    useComposerAttachments: 1,
+    useEffect: 14,
+    useGoalController: 1,
+    useKeyboardHelp: 1,
+    useLayoutEffect: 2,
+    useModuleHubController: 1,
+    useNewTaskChoice: 1,
+    useOnboardingSnapshot: 1,
+    usePlanModeState: 1,
+    useSessionEventHealthPolling: 1,
+    // Replaces `useSessionNavigationController`, which is now called inside
+    // `SessionNavigationProvider`. The entry shrinks rather than disappearing,
+    // because the shell body does read the rail: the command palette lists the
+    // same visible sessions, the titlebar shows the linked parent, and the
+    // frame publishes the rail's width as `--maka-sidenav-width`. What is left
+    // holds nothing — three `useMemo`s over the catalog the shell already reads
+    // and one subscription to the rail's geometry — where the controller it
+    // replaces put three `useState`, four effects and a `useStableActions`
+    // facade on this fiber.
+    useSessionNavigationReads: 1,
+    useSessionSettingIntent: 2,
+    useSettingsModal: 1,
+    useShellAppearance: 1,
+    useShellChatModel: 1,
+    useShellConnections: 3,
+    useShellLiveTurn: 1,
+    useShellMemoryPill: 1,
+    useShellResume: 1,
+    useShellRunUpdates: 1,
+    useShellSearch: 1,
+    useStableActions: 7,
+    useState: 16,
+    useTaskEntryController: 1,
+    useTaskSubmissionReadiness: 1,
+    useToast: 1,
+    // The last of the three `useKeyedPendingRegistry` call sites this entry
+    // replaces: #4113 moved the other two onto the session UI store, which is
+    // already an external store, so their scope left this fiber entirely. This
+    // one stays because the shell body reads `keys` to build the turn footer's
+    // disabled mask.
+    useTurnActionRegistry: 1,
+    useWorkbarController: 1,
+  },
+};
+
+/**
+ * After these, a `/` opens a regex literal; anywhere else it is division.
+ *
+ * `<` and `>` are deliberately absent. They would qualify in expression
+ * position, but in TSX they are far more often a tag: treating the `/` of
+ * `</div>` as the start of a regex swallows everything up to the next slash,
+ * which is exactly the silent under-count this gate must not have.
+ */
+const REGEX_MAY_FOLLOW = new Set([
+  '(',
+  ',',
+  '=',
+  ':',
+  '[',
+  '!',
+  '&',
+  '|',
+  '?',
+  '{',
+  '}',
+  ';',
+  '+',
+  '-',
+  '*',
+  '%',
+  '^',
+  '~',
+  '\n',
+]);
+
+/**
+ * Blanks comments, string bodies and regex literals, replacing each with spaces
+ * so that offsets and line numbers still line up with the original.
+ *
+ * Prose naming a hook is not a call site, and the shell's comments name hooks
+ * at length. The two cases worth spelling out are the ones that break a naive
+ * scanner: an apostrophe in JSX text (`<span>don't</span>`) is not a string
+ * delimiter, and a regex literal may contain quotes (`/['"]/`) that would
+ * otherwise swallow the rest of the file. Both would UNDER-count, which is the
+ * dangerous direction for a gate whose only product is a number.
+ */
+export function stripNonCode(source) {
+  const out = [...source];
+  let index = 0;
+  let lastMeaningful = '\n';
+  const blank = (from, to) => {
+    for (let i = from; i < to && i < out.length; i += 1) {
+      if (out[i] !== '\n') out[i] = ' ';
+    }
+  };
+  while (index < source.length) {
+    const char = source[index];
+    const next = source[index + 1];
+    if (char === '/' && next === '/') {
+      const end = source.indexOf('\n', index);
+      const stop = end === -1 ? source.length : end;
+      blank(index, stop);
+      index = stop;
+      continue;
+    }
+    if (char === '/' && next === '*') {
+      const end = source.indexOf('*/', index + 2);
+      const stop = end === -1 ? source.length : end + 2;
+      blank(index, stop);
+      index = stop;
+      continue;
+    }
+    if (char === '/' && REGEX_MAY_FOLLOW.has(lastMeaningful)) {
+      let scan = index + 1;
+      let inClass = false;
+      while (scan < source.length) {
+        const c = source[scan];
+        if (c === '\\') scan += 2;
+        else if (c === '[') (inClass = true), (scan += 1);
+        else if (c === ']') (inClass = false), (scan += 1);
+        else if (c === '\n') break;
+        else if (c === '/' && !inClass) break;
+        else scan += 1;
+      }
+      // An unterminated candidate was division after all; leave it alone.
+      if (scan < source.length && source[scan] === '/') {
+        blank(index, scan + 1);
+        lastMeaningful = '/';
+        index = scan + 1;
+        continue;
+      }
+    }
+    if (char === "'" || char === '"' || char === '`') {
+      // In JSX text an apostrophe follows a word or a closing bracket, and no
+      // JavaScript expression can put a string literal there.
+      if (char === "'" && /[A-Za-z0-9_$>)\]}]/.test(lastMeaningful)) {
+        lastMeaningful = char;
+        index += 1;
+        continue;
+      }
+      let scan = index + 1;
+      while (scan < source.length && source[scan] !== char) {
+        scan += source[scan] === '\\' ? 2 : 1;
+      }
+      blank(index + 1, scan);
+      lastMeaningful = char;
+      index = Math.min(scan + 1, source.length);
+      continue;
+    }
+    if (!/\s/.test(char) || char === '\n') lastMeaningful = char;
+    index += 1;
+  }
+  return out.join('');
+}
+
+/**
+ * The body of a top-level component, delimited by brace balancing over text
+ * whose strings and comments are already blanked. A `\n}\n` guess would stop at
+ * the first brace that formatting happened to put in column zero.
+ */
+export function findComponentBody(blanked, name) {
+  const declaration = new RegExp(
+    String.raw`^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+${name}\b|^(?:export\s+)?(?:default\s+)?const\s+${name}\s*(?::[^=]+)?=`,
+    'm',
+  );
+  const match = declaration.exec(blanked);
+  if (match === null) return null;
+  // Skip the parameter list. `function AppShellContent({ ... })` opens a brace
+  // for its destructured parameter, and balancing from there returns the
+  // parameter object rather than the body.
+  const paren = blanked.indexOf('(', match.index);
+  if (paren === -1) return null;
+  let parens = 0;
+  let afterParams = -1;
+  for (let i = paren; i < blanked.length; i += 1) {
+    if (blanked[i] === '(') parens += 1;
+    else if (blanked[i] === ')') {
+      parens -= 1;
+      if (parens === 0) {
+        afterParams = i + 1;
+        break;
+      }
+    }
+  }
+  if (afterParams === -1) return null;
+  const open = blanked.indexOf('{', afterParams);
+  if (open === -1) return null;
+  let depth = 0;
+  for (let i = open; i < blanked.length; i += 1) {
+    if (blanked[i] === '{') depth += 1;
+    else if (blanked[i] === '}') {
+      depth -= 1;
+      if (depth === 0) return blanked.slice(open, i + 1);
+    }
+  }
+  return null;
+}
+
+/**
+ * `useX(`, `React.useX(`, `useX<T>(`, `useX\n  (` and the bare `use(` of React
+ * 19 all call a hook; `ReturnType<typeof useX>` does not, and neither does
+ * `shellCopy.useSkillPrompt(name)` — the shell really does call that one.
+ *
+ * That last case is why the qualifier is the literal `React.` and not any
+ * identifier: a general `<name>.useX(` rule would count a locale copy function
+ * as a hook. The narrow rule leaves one hole, `import * as X from 'react'`,
+ * which `assertCountableReactImport` closes by failing rather than by guessing.
+ */
+// One level of nesting is enough for `useX<Record<string, number>>(...)`;
+// deeper generics in a hook call do not occur here and would only cost a
+// miscount in the safe direction (a failure, not a silent pass).
+const TYPE_ARGUMENTS = String.raw`<(?:[^<>()]|<[^<>()]*>)*>`;
+const HOOK_CALL = new RegExp(
+  String.raw`(?<![.\w$])(?:React\.)?(use(?:[A-Z][A-Za-z0-9]*)?)(?=\s*(?:${TYPE_ARGUMENTS}\s*)?\()`,
+  'g',
+);
+
+/**
+ * A namespace import of React would let `X.useState(...)` past a scanner that
+ * only knows the name `React`, and the whole point of this gate is that it
+ * cannot be widened by accident. It has no way to resolve the alias, so it
+ * refuses the file instead of under-counting it.
+ */
+export function assertCountableReactImport(source) {
+  const namespaceImport = /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]react['"]/.exec(
+    source,
+  );
+  if (!namespaceImport) return null;
+  return (
+    `imports React as the namespace \`${namespaceImport[1]}\`.\n` +
+    '  This gate counts hook calls textually and knows only the name `React`,\n' +
+    '  so `' +
+    namespaceImport[1] +
+    '.useState(...)` would be invisible to it.\n' +
+    '  Use named imports, or teach the scanner the alias.'
+  );
+}
+
+export function countHooks(body) {
+  const counts = {};
+  for (const match of body.matchAll(HOOK_CALL)) {
+    // `React.useState` is the same call site as `useState`, and the inventory
+    // names the hook, not the syntax that reached it.
+    const name = match[1];
+    if (DERIVED_HOOKS.has(name)) continue;
+    counts[name] = (counts[name] ?? 0) + 1;
+  }
+  return counts;
+}
+
+export function compareToInventory(counts, allowed) {
+  const added = [];
+  const grown = [];
+  const stale = [];
+  for (const [name, count] of Object.entries(counts)) {
+    const budget = allowed[name];
+    if (budget === undefined) added.push(name);
+    else if (count > budget) grown.push({ name, budget, count });
+  }
+  for (const [name, budget] of Object.entries(allowed)) {
+    const count = counts[name] ?? 0;
+    if (count < budget) stale.push({ name, budget, count });
+  }
+  return { added, grown, stale };
+}
+
+function main() {
+  const source = readFileSync(join(root, shellFile), 'utf8');
+  // Against the raw source: `stripNonCode` blanks the `'react'` specifier.
+  const uncountableImport = assertCountableReactImport(source);
+  if (uncountableImport) {
+    console.error(`${shellFile}: ${uncountableImport}`);
+    process.exit(1);
+  }
+  const blanked = stripNonCode(source);
+  const failures = [];
+  let hooks = 0;
+  let callSites = 0;
+
+  for (const [component, allowed] of Object.entries(ALLOWED)) {
+    const body = findComponentBody(blanked, component);
+    if (body === null) {
+      console.error(`${shellFile}: could not find the render body of ${component}`);
+      process.exit(1);
+    }
+
+    const counts = countHooks(body);
+    hooks += Object.keys(counts).length;
+    callSites += Object.values(counts).reduce((sum, n) => sum + n, 0);
+    const { added, grown, stale } = compareToInventory(counts, allowed);
+
+    for (const name of added) {
+      failures.push(
+        `${shellFile}: ${name} is a new hook in ${component}'s render body.\n` +
+          '  Its state would be scoped to the whole tree. Call it from the feature\n' +
+          '  provider instead; add it to the inventory only if it genuinely belongs\n' +
+          '  to the shell, and say why in the pull request (#4109).',
+      );
+    }
+    for (const { name, budget, count } of grown) {
+      failures.push(
+        `${shellFile}: ${name} has ${count} call sites in ${component}, inventory allows ${budget}.`,
+      );
+    }
+    for (const { name, budget, count } of stale) {
+      // Deliberately not phrased as an instruction to lower the number. A drop
+      // is usually a migration, but it is also what a mis-count looks like, and
+      // a gate that tells you to shrink its own inventory can be talked into
+      // emptying itself.
+      failures.push(
+        `${shellFile}: ${name} has ${count} call sites in ${component}, inventory says ${budget}.\n` +
+          '  If a migration moved it, update the entry in scripts/check-app-shell-hooks.mjs.\n' +
+          '  If you did not move it, the gate is miscounting — fix the gate, not the number.',
+      );
+    }
+  }
+
+  if (failures.length === 0) {
+    console.log(
+      `app-shell hook scope: ok (${hooks} hooks, ${callSites} call sites across ${Object.keys(ALLOWED).join(' + ')})`,
+    );
+    return;
+  }
+  for (const failure of failures) console.error(failure);
+  process.exit(1);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
diff --git a/scripts/check-app-shell-hooks.test.mjs b/scripts/check-app-shell-hooks.test.mjs
new file mode 100644
index 000000000..9702435b5
--- /dev/null
+++ b/scripts/check-app-shell-hooks.test.mjs
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import {
+  ALLOWED,
+  assertCountableReactImport,
+  compareToInventory,
+  countHooks,
+  findComponentBody,
+  stripNonCode,
+} from './check-app-shell-hooks.mjs';
+
+/** Counts hooks the way the gate does: blank first, then delimit, then match. */
+function countIn(source, component) {
+  const body = findComponentBody(stripNonCode(source), component);
+  return body === null ? null : countHooks(body);
+}
+
+const SHELL = `
+export function AppShell() {
+  const [locale, setLocale] = useState<UiLocalePreference>('auto');
+  useSystemUiLocale();
+  return <AppShellContent />;
+}
+
+function AppShellContent({ prop }: Props) {
+  const [value, setValue] = useState(0);
+  const derived = useMemo(() => value, [value]);
+  const chat = useShellChatModel({ value });
+  return <div>{derived}</div>;
+}
+
+function AfterTheShell() {
+  useSomethingElse();
+}
+`;
+
+test('each shell component is delimited by its own braces', () => {
+  assert.deepEqual(countIn(SHELL, 'AppShellContent'), {
+    useState: 1,
+    useShellChatModel: 1,
+  });
+  assert.deepEqual(countIn(SHELL, 'AppShell'), {
+    useState: 1,
+    useSystemUiLocale: 1,
+  });
+});
+
+test('a destructured parameter is not mistaken for the body', () => {
+  // `function C({ a, b }: Props) {` opens a brace before the body does.
+  const source = 'function C({ a, b }: Props) {\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('an unknown component is reported rather than guessed at', () => {
+  assert.equal(findComponentBody(stripNonCode(SHELL), 'NoSuchComponent'), null);
+});
+
+test('an arrow component is found too, so a refactor cannot silence the gate', () => {
+  const source = 'const C = ({ a }: Props) => {\n  useToast();\n};\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('an exported declaration is found', () => {
+  const source = 'export function C() {\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a brace in column zero does not end the body early', () => {
+  // Formatting can leave a closing brace at column zero inside a body; the old
+  // `\n}\n` delimiter stopped there and silently dropped every later hook.
+  const source = [
+    'function C() {',
+    '  const style = {',
+    '  a: 1,',
+    '};',
+    '  useToast();',
+    '}',
+    '',
+  ].join('\n');
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('an explicit type argument is still a call site', () => {
+  // The gate under-counted 12 real call sites in app-shell.tsx by requiring a
+  // `(` directly after the name.
+  const source = [
+    'function C() {',
+    '  const [a, setA] = useState<string | null>(null);',
+    '  const b = useNewTaskChoice<ChatDefaultPermissionMode>(key);',
+    '  const c = useSessionSettingIntent<Record<string, number>>({});',
+    '}',
+    '',
+  ].join('\n');
+  assert.deepEqual(countIn(source, 'C'), {
+    useState: 1,
+    useNewTaskChoice: 1,
+    useSessionSettingIntent: 1,
+  });
+});
+
+test('a member call on something other than React is not a hook', () => {
+  const source = 'function C() {\n  copy.useSkillPrompt(name);\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+// The gate's whole value is that it fails closed. Every form React supports has
+// to be counted, or a future subscription widens the shell's scope while this
+// exact inventory stays green.
+test('the React namespace form is the same call site as the bare name', () => {
+  const source =
+    'function C() {\n  React.useState(0);\n  useState(1);\n  React.useEffect(fn);\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useState: 2, useEffect: 1 });
+});
+
+test("React 19's bare `use` is a hook, and identifiers merely starting with `use` are not", () => {
+  const source =
+    'function C() {\n  use(promise);\n  user(id);\n  usedBy(x);\n  router.use(mw);\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { use: 1 });
+});
+
+test('a namespace import of React is refused rather than under-counted', () => {
+  assert.equal(assertCountableReactImport("import { useState } from 'react';"), null);
+  assert.equal(assertCountableReactImport("import * as Lodash from 'lodash';"), null);
+  assert.match(
+    assertCountableReactImport("import * as R from 'react';") ?? '',
+    /imports React as the namespace `R`/,
+  );
+});
+
+test('a type position is not a call site', () => {
+  const source =
+    'function C() {\n  const x: ReturnType<typeof useShellSearch> = y;\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a call split across lines is still a call site', () => {
+  const source = 'function C() {\n  const x = useToast\n    ({});\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('prose naming a hook is not a call site', () => {
+  const source = [
+    'function C() {',
+    '  // The model lives in useShellChatModel (a pure derivation).',
+    '  /* See useGoalController () for the other half. */',
+    "  const label = 'useToast ()';",
+    '  useSettingsModal({});',
+    '}',
+    '',
+  ].join('\n');
+  assert.deepEqual(countIn(source, 'C'), { useSettingsModal: 1 });
+});
+
+test('an apostrophe in JSX text does not swallow the rest of the body', () => {
+  const source = "function C() {\n  const t = <span>don't</span>;\n  useToast();\n}\n";
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a regex literal containing a quote does not swallow the rest of the body', () => {
+  const source = 'function C() {\n  const re = /[\'"]/g;\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a JSX closing tag is not the start of a regex literal', () => {
+  const source = 'function C() {\n  const t = <div></div>;\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('an escaped quote does not swallow the rest of the body', () => {
+  const source = "function C() {\n  const label = 'it\\'s here';\n  useToast();\n}\n";
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a URL is not mistaken for a line comment', () => {
+  assert.match(stripNonCode('const url = ok; // https://example.com\nuseToast();'), /useToast/);
+});
+
+test('blanking preserves offsets, so a body keeps its shape', () => {
+  const source = "const a = 'xxxx'; // yyyy\nuseToast();\n";
+  assert.equal(stripNonCode(source).length, source.length);
+  assert.equal(stripNonCode(source).split('\n').length, source.split('\n').length);
+});
+
+test('purely derived hooks do not widen a scope, so they are not counted', () => {
+  const source =
+    'function C() {\n  useMemo(() => 1, []);\n  useCallback(() => {}, []);\n  useRef(null);\n  useId();\n  useToast();\n}\n';
+  assert.deepEqual(countIn(source, 'C'), { useToast: 1 });
+});
+
+test('a new hook in the render body fails the gate', () => {
+  const { added } = compareToInventory({ useState: 1, useBrandNew: 1 }, { useState: 1 });
+  assert.deepEqual(added, ['useBrandNew']);
+});
+
+test('a second call site of an inventoried hook fails the gate', () => {
+  const { grown } = compareToInventory({ useState: 2 }, { useState: 1 });
+  assert.deepEqual(grown, [{ name: 'useState', budget: 1, count: 2 }]);
+});
+
+test('a migrated hook fails until its entry is deleted, so the gate converges', () => {
+  const { stale } = compareToInventory({}, { useComposerMentions: 1 });
+  assert.deepEqual(stale, [{ name: 'useComposerMentions', budget: 1, count: 0 }]);
+});
+
+test('the committed inventory matches the shell as it stands', async () => {
+  const source = await readFile(
+    new URL('../apps/desktop/src/renderer/app-shell.tsx', import.meta.url),
+    'utf8',
+  );
+  const blanked = stripNonCode(source);
+  for (const [component, allowed] of Object.entries(ALLOWED)) {
+    const body = findComponentBody(blanked, component);
+    assert.notEqual(body, null, `${component} must still be a top-level component`);
+    assert.deepEqual(compareToInventory(countHooks(body), allowed), {
+      added: [],
+      grown: [],
+      stale: [],
+    });
+  }
+});
+
+test('the shell body is delimited to something plausible, not to the whole file', async () => {
+  // A delimiter that ran to end-of-file would still satisfy the inventory while
+  // silently counting whatever follows the component.
+  const source = await readFile(
+    new URL('../apps/desktop/src/renderer/app-shell.tsx', import.meta.url),
+    'utf8',
+  );
+  const blanked = stripNonCode(source);
+  const outer = findComponentBody(blanked, 'AppShell');
+  const inner = findComponentBody(blanked, 'AppShellContent');
+  assert.ok(outer.length < inner.length, 'the wrapper is far smaller than the content');
+  assert.ok(inner.length < blanked.length, 'the content body is not the entire file');
+  assert.doesNotMatch(outer, /useShellChatModel/, 'the wrapper must not absorb the content');
+});
diff --git a/scripts/perf/README.md b/scripts/perf/README.md
new file mode 100644
index 000000000..be5804b9d
--- /dev/null
+++ b/scripts/perf/README.md
@@ -0,0 +1,88 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+# Renderer performance probes
+
+Measuring what a session switch costs in the running Desktop app, over CDP. The
+findings in #4109 were produced with these; they live here so the next
+measurement is a command rather than a rebuild.
+
+They are ad-hoc tools, not a benchmark suite: they attach to a dev app you
+started, and they answer "how much" only for the interaction they drive.
+
+## Running
+
+Start the dev app with the debugger open:
+
+```bash
+npm run dev -w @maka/desktop -- --remote-debugging-port=9334
+```
+
+Then, with a populated sidebar (about 30 rows is what the #4109 numbers used):
+
+```bash
+node scripts/perf/session-switch-commits.mjs before
+node scripts/perf/session-switch-busy-js.mjs
+```
+
+- `session-switch-commits.mjs` — React commits per switch, how many are
+  full-tree renders, and how many fibers actually re-rendered. Reloads the page
+  first, so `react-commit-probe.js` is in place before React boots.
+- `session-switch-busy-js.mjs` — renderer busy JS per switch, with the top
+  self-time entries. It reports the running build, one configuration. A
+  before/after needs both configurations in one instance; see below.
+- `react-commit-probe.js` — the in-page half: a minimal
+  `__REACT_DEVTOOLS_GLOBAL_HOOK__` plus a `Function.prototype.bind` wrapper that
+  catches React creating a `dispatchSetState`, so a commit can be attributed to
+  the `setState` that caused it.
+- `cdp-client.mjs` — the protocol client and the row-clicking helper.
+
+## The one rule
+
+**Never compare numbers from two app launches.** Restarting the app shifts these
+metrics by orders of magnitude, while the spread inside a single running
+instance is small. An A/B means: one instance, a `globalThis` switch to select
+the configuration, at least three repetitions per configuration, alternating,
+compared pairwise. Two runs of "before" and "after" against two launches will
+produce a confident number that means nothing.
+
+The switch is a throwaway, not a shipped affordance. Branch a worktree, put a
+`globalThis` flag in the one place that reads it — one that restores the old
+behaviour when set, so the regressed configuration is reachable without
+rebuilding the defect by hand — alternate the flag inside the running instance,
+then delete the worktree. Measurement discipline is ours; product code carries
+none of it. A flag that reaches `main` is a branch nothing executes and a
+defect replica that has to be maintained against the code it replicates.
+
+Two smaller ones that cost time to rediscover:
+
+- Clicks must be `Input.dispatchMouseEvent`. A synthesised `element.click()`
+  does not activate an Astryx `SideNavItem`, so the app does nothing and the
+  measurement describes it.
+- Disarm the commit probe (`__MAKA_PROBE__.arm = false`) before sampling. Its
+  fiber walk otherwise shows up in the profile as the app's own work;
+  `session-switch-busy-js.mjs` does this for you.
+
+## What these do not tell you
+
+The commit count is unweighted, and busy JS on this workload is spread across
+about a thousand cheap fibers rather than concentrated in one component. A
+change that lowers the commit count has not necessarily lowered the time, and a
+change that moves the top self-time entry has not necessarily lowered anything.
+Read both, and read the medians.
diff --git a/scripts/perf/cdp-client.mjs b/scripts/perf/cdp-client.mjs
new file mode 100644
index 000000000..ce41d5a75
--- /dev/null
+++ b/scripts/perf/cdp-client.mjs
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * A Chrome DevTools Protocol client for the running dev app (#4109).
+ *
+ * Small on purpose: `/json/list` to find the renderer target, one WebSocket,
+ * `Runtime.evaluate` and `Input.dispatchMouseEvent`. Nothing here is specific
+ * to a measurement — the probes in this directory are.
+ *
+ * Clicks must go through `Input.dispatchMouseEvent`. A synthesised
+ * `element.click()` does not produce the event sequence Astryx's SideNavItem
+ * listens for, so the row never activates and the measurement silently
+ * describes an app that did nothing.
+ */
+
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const WebSocket = require('ws');
+
+export const DEFAULT_PORT = 9334;
+
+export async function targets(port = DEFAULT_PORT) {
+  const response = await fetch(`http://127.0.0.1:${port}/json/list`);
+  return response.json();
+}
+
+export async function connectRenderer(port = DEFAULT_PORT, match = /index\.html|maka/i) {
+  const list = await targets(port);
+  const page = list.find(
+    (target) => target.type === 'page' && (match.test(target.url) || match.test(target.title)),
+  );
+  if (!page) {
+    const seen = list.map((target) => `${target.type} ${target.title} ${target.url}`).join('\n  ');
+    throw new Error(`no renderer target on port ${port}. Saw:\n  ${seen}`);
+  }
+  return connect(page.webSocketDebuggerUrl);
+}
+
+export function connect(url) {
+  const socket = new WebSocket(url, { perMessageDeflate: false, maxPayload: 512 * 1024 * 1024 });
+  let nextId = 0;
+  const pending = new Map();
+  const listeners = [];
+  const ready = new Promise((resolve, reject) => {
+    socket.once('open', resolve);
+    socket.once('error', reject);
+  });
+
+  socket.on('message', (data) => {
+    const message = JSON.parse(data.toString());
+    if (message.id === undefined) {
+      for (const listener of listeners) listener(message);
+      return;
+    }
+    const waiter = pending.get(message.id);
+    pending.delete(message.id);
+    if (!waiter) return;
+    if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
+    else waiter.resolve(message.result);
+  });
+
+  const send = (method, params = {}) =>
+    new Promise((resolve, reject) => {
+      const id = ++nextId;
+      pending.set(id, { resolve, reject });
+      socket.send(JSON.stringify({ id, method, params }));
+    });
+
+  const evaluate = async (expression, options = {}) => {
+    const result = await send('Runtime.evaluate', {
+      expression,
+      returnByValue: true,
+      awaitPromise: true,
+      ...options,
+    });
+    if (result.exceptionDetails) {
+      throw new Error(
+        JSON.stringify(result.exceptionDetails.exception?.description ?? result.exceptionDetails),
+      );
+    }
+    return result.result.value;
+  };
+
+  return {
+    socket,
+    send,
+    evaluate,
+    on: (listener) => listeners.push(listener),
+    ready,
+    close: () => socket.close(),
+  };
+}
+
+export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Click the nth Session row by real input events, at the centre of its box.
+ *
+ * Scoped to the rail and refused when the row is not on screen. A click at
+ * coordinates outside the viewport lands on whatever is there instead — one of
+ * them opened Settings mid-run and the measurement that followed described an
+ * app that was not showing the rail at all.
+ */
+export async function clickSessionRow(client, index) {
+  const box = JSON.parse(
+    await client.evaluate(
+      `(() => {
+        const rail = document.querySelector('nav.maka-session-panel');
+        if (!rail) throw new Error('the session rail is not mounted');
+        const row = [...rail.querySelectorAll('[data-session-id]')][${index}];
+        if (!row) throw new Error('no session row at index ${index}');
+        const b = row.getBoundingClientRect();
+        const x = b.x + b.width / 2;
+        const y = b.y + b.height / 2;
+        if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) {
+          throw new Error('session row ${index} is off screen (' + x + ',' + y + ')');
+        }
+        return JSON.stringify({ x, y });
+      })()`,
+    ),
+  );
+  const point = {
+    x: Math.round(box.x),
+    y: Math.round(box.y),
+    button: 'left',
+    clickCount: 1,
+    buttons: 1,
+  };
+  await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point, buttons: 0 });
+  await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point });
+  await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, buttons: 0 });
+}
+
+export function median(values) {
+  const sorted = [...values].sort((a, b) => a - b);
+  return sorted[Math.floor(sorted.length / 2)];
+}
diff --git a/scripts/perf/react-commit-probe.js b/scripts/perf/react-commit-probe.js
new file mode 100644
index 000000000..155e6834d
--- /dev/null
+++ b/scripts/perf/react-commit-probe.js
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Counts React commits, rendered fibers, and the `setState` calls that caused
+ * them, from inside the renderer (#4109).
+ *
+ * Injected with `Page.addScriptToEvaluateOnNewDocument` before the app loads,
+ * because both halves have to be in place first:
+ *
+ *   1. `__REACT_DEVTOOLS_GLOBAL_HOOK__` must exist before `react-dom` looks for
+ *      it, or no commit is ever reported. This is a minimal stand-in for the
+ *      DevTools hook — enough of the shape that react-dom injects into it.
+ *
+ *   2. `Function.prototype.bind` is wrapped to catch React creating a state
+ *      dispatcher. React binds `dispatchSetState` as `(null, fiber, queue)`,
+ *      and the queue is the only object in the runtime carrying
+ *      `lastRenderedReducer`, so that triple identifies it with no version
+ *      coupling to react-dom's internals.
+ *
+ * A fiber counts as rendered when it carries `Placement` (`flags & 1`) and its
+ * alternate was last seen in the previous commit. That is identity-insensitive:
+ * it reports what React actually re-rendered, not what a `memo` comparator was
+ * asked about.
+ *
+ * `arm` gates recording so a measurement covers one interaction rather than the
+ * app's whole life. Set `__MAKA_PROBE__.arm = false` before profiling with the
+ * sampler, or the probe's own walk lands in the profile.
+ */
+(() => {
+  if (globalThis.__MAKA_PROBE__) return 'already';
+  const probe = {
+    commits: [],
+    dispatches: [],
+    arm: false,
+    roots: new Set(),
+    rootIdx: new WeakMap(),
+    qid: 0,
+    qlabel: new Map(),
+  };
+  globalThis.__MAKA_PROBE__ = probe;
+
+  const originalBind = Function.prototype.bind;
+  Function.prototype.bind = function bind(...args) {
+    const bound = originalBind.apply(this, args);
+    try {
+      const queue = args[2];
+      if (
+        args.length === 3 &&
+        args[0] === null &&
+        queue &&
+        typeof queue === 'object' &&
+        'lastRenderedReducer' in queue &&
+        !queue.__qid
+      ) {
+        queue.__qid = ++probe.qid;
+        const qid = queue.__qid;
+        return function dispatchWithRecord(action) {
+          if (probe.arm && probe.dispatches.length < 500) {
+            probe.dispatches.push({
+              t: performance.now(),
+              qid,
+              prev: brief(queue.lastRenderedState),
+              action: brief(action),
+              st: (new Error().stack || '')
+                .split('\n')
+                .slice(2, 8)
+                .map((line) => line.trim().slice(0, 130)),
+            });
+          }
+          return bound.apply(this, arguments);
+        };
+      }
+    } catch {
+      // A bind we cannot inspect is a bind we do not care about.
+    }
+    return bound;
+  };
+
+  function brief(value) {
+    try {
+      if (value == null) return String(value);
+      if (typeof value === 'function') return 'fn';
+      if (typeof value !== 'object') return String(value).slice(0, 40);
+      if (Array.isArray(value)) return `Arr${value.length}`;
+      return `{${Object.keys(value).slice(0, 6)}}`;
+    } catch {
+      return '?';
+    }
+  }
+
+  const nameOf = (fiber) => {
+    const type = fiber.type ?? fiber.elementType;
+    if (typeof type === 'string') return type;
+    if (typeof type === 'function') return type.displayName || type.name || 'anon';
+    if (type && typeof type === 'object') {
+      const inner = type.type ?? type.render;
+      return (
+        type.displayName ||
+        (typeof inner === 'function' ? inner.displayName || inner.name : null) ||
+        'obj'
+      );
+    }
+    return `tag${fiber.tag}`;
+  };
+
+  const hook = {
+    renderers: new Map(),
+    supportsFiber: true,
+    _nextId: 1,
+    inject(renderer) {
+      const id = hook._nextId++;
+      hook.renderers.set(id, renderer);
+      return id;
+    },
+    checkDCE() {},
+    onScheduleFiberRoot() {},
+    onCommitFiberUnmount() {},
+    onPostCommitFiberRoot() {},
+    setStrictMode() {},
+    getFiberRoots() {
+      return probe.roots;
+    },
+    on() {},
+    off() {},
+    sub() {
+      return () => {};
+    },
+    emit() {},
+    onCommitFiberRoot(_id, root) {
+      probe.roots.add(root);
+      const idx = (probe.rootIdx.get(root) || 0) + 1;
+      probe.rootIdx.set(root, idx);
+      const key = '__pc';
+      let total = 0;
+      let visited = 0;
+      const renderRoots = [];
+      const stack = [[root.current, 0, false]];
+      while (stack.length) {
+        const [fiber, depth, hasRenderedAncestor] = stack.pop();
+        if (!fiber || visited++ > 90000) break;
+        const alternate = fiber.alternate;
+        const rendered = fiber.flags & 1 && alternate && alternate[key] === idx - 1;
+        if (rendered) total++;
+        let ancestor = hasRenderedAncestor;
+        if (rendered && !hasRenderedAncestor && probe.arm) {
+          ancestor = true;
+          renderRoots.push({ d: depth, name: nameOf(fiber) });
+        }
+        let state = fiber.memoizedState;
+        let i = 0;
+        while (state && i < 1200) {
+          if (state.queue?.__qid && !probe.qlabel.has(state.queue.__qid)) {
+            probe.qlabel.set(state.queue.__qid, `${nameOf(fiber)}@d${depth}#h${i}`);
+          }
+          state = state.next;
+          i++;
+        }
+        fiber[key] = idx;
+        if (fiber.child) stack.push([fiber.child, depth + 1, ancestor]);
+        if (fiber.sibling) stack.push([fiber.sibling, depth, hasRenderedAncestor]);
+      }
+      if (probe.arm && probe.commits.length < 300) {
+        renderRoots.sort((a, b) => a.d - b.d);
+        probe.commits.push({ t: performance.now(), idx, total, rr: renderRoots.slice(0, 3) });
+      }
+    },
+  };
+  Object.defineProperty(globalThis, '__REACT_DEVTOOLS_GLOBAL_HOOK__', {
+    value: hook,
+    writable: false,
+    configurable: true,
+  });
+  return 'installed';
+})();
diff --git a/scripts/perf/session-switch-busy-js.mjs b/scripts/perf/session-switch-busy-js.mjs
new file mode 100644
index 000000000..d9646bc81
--- /dev/null
+++ b/scripts/perf/session-switch-busy-js.mjs
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Renderer busy JS per session switch, with the top self-time entries (#4109).
+ *
+ * Usage, against a running dev app with `--remote-debugging-port=9334`:
+ *
+ *   node scripts/perf/session-switch-busy-js.mjs [port] [trials]
+ *
+ * This reports one configuration: whatever the running build does. A before/
+ * after needs both configurations reachable from ONE running instance — see the
+ * README for how to get that from a throwaway worktree instead of from a switch
+ * carried in product code.
+ *
+ * Sampling at 100µs. Busy time is every sample that is not `(idle)` or
+ * `(program)`; the top-8 self-time list is there to show WHERE the time is, and
+ * on this workload it is deliberately flat — about a thousand cheap fibers, no
+ * hot component. A change that only moves the top entry around has not moved
+ * the total.
+ *
+ * The same discipline as the commit counter: alternate configurations inside
+ * ONE running instance and compare paired trials. Numbers from two launches are
+ * not comparable.
+ */
+
+import { clickSessionRow, connectRenderer, median, sleep, DEFAULT_PORT } from './cdp-client.mjs';
+
+const positional = process.argv.slice(2).filter((arg) => !arg.startsWith('--'));
+const port = Number(positional[0] ?? DEFAULT_PORT);
+const trials = Number(positional[1] ?? 5);
+const SETTLE_MS = 2000;
+
+const client = await connectRenderer(port);
+await client.ready;
+await client.send('Profiler.enable');
+
+// The commit probe walks the fiber tree on every commit. Left armed it lands in
+// the profile as the app's own work.
+await client.evaluate('globalThis.__MAKA_PROBE__ && (__MAKA_PROBE__.arm = false)').catch(() => {});
+
+async function measureSwitch(row) {
+  await client.send('Profiler.setSamplingInterval', { interval: 100 });
+  await client.send('Profiler.start');
+  await clickSessionRow(client, row);
+  await sleep(SETTLE_MS);
+  const { profile } = await client.send('Profiler.stop');
+
+  const byId = new Map(profile.nodes.map((node) => [node.id, node]));
+  const selfTime = new Map();
+  const deltas = profile.timeDeltas ?? [];
+  let busy = 0;
+  let idle = 0;
+  for (let i = 0; i < profile.samples.length; i++) {
+    const node = byId.get(profile.samples[i]);
+    const dt = (deltas[i] ?? 0) / 1000;
+    const name = node?.callFrame?.functionName || '(anonymous)';
+    if (name === '(idle)' || name === '(program)') {
+      idle += dt;
+      continue;
+    }
+    busy += dt;
+    const file = (node?.callFrame?.url || '').split('/').pop() || '';
+    const key = `${name} @${file}:${node?.callFrame?.lineNumber}`;
+    selfTime.set(key, (selfTime.get(key) ?? 0) + dt);
+  }
+  return { busy, idle, selfTime };
+}
+
+function printTop(selfTime) {
+  for (const [key, value] of [...selfTime.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)) {
+    console.log(`    ${value.toFixed(0)}ms  ${key.slice(0, 110)}`);
+  }
+}
+
+const busyPerSwitch = [];
+for (let trial = 0; trial < trials; trial++) {
+  const row = 2 + trial;
+  const { busy, idle, selfTime } = await measureSwitch(row);
+  busyPerSwitch.push(busy);
+  console.log(`row ${row}: busyJS=${busy.toFixed(0)}ms idle=${idle.toFixed(0)}ms`);
+  printTop(selfTime);
+}
+console.log(`\nmedian busy JS per switch = ${median(busyPerSwitch).toFixed(0)}ms`);
+
+client.close();
diff --git a/scripts/perf/session-switch-commits.mjs b/scripts/perf/session-switch-commits.mjs
new file mode 100644
index 000000000..5629bc507
--- /dev/null
+++ b/scripts/perf/session-switch-commits.mjs
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * How many React commits one session switch costs, and how much of each is a
+ * full-tree render (#4109).
+ *
+ * Usage, against a running dev app with `--remote-debugging-port=9334`:
+ *
+ *   node scripts/perf/session-switch-commits.mjs <label> [port]
+ *
+ * Reloads the page to install `react-commit-probe.js` before React boots, then
+ * switches sessions several times and reports the median. Read the medians, not
+ * a single row: the first switch after a reload pays for caches the rest do
+ * not, which is why row 1 is a warm-up and is excluded.
+ *
+ * NEVER compare two numbers from two app launches. Restarting the app moves
+ * these metrics by an order of magnitude while the spread inside one instance
+ * is small; an A/B has to alternate configurations inside one running instance
+ * and compare paired trials.
+ */
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { clickSessionRow, connectRenderer, median, sleep, DEFAULT_PORT } from './cdp-client.mjs';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const label = process.argv[2] ?? 'run';
+const port = Number(process.argv[3] ?? DEFAULT_PORT);
+
+/** A full-tree render: React re-rendered essentially everything under the root. */
+const FULL_TREE_FIBERS = 1000;
+const SETTLE_MS = 2500;
+const BOOT_MS = 9000;
+const ROWS = [2, 3, 4, 5, 6, 7, 8, 9];
+
+const client = await connectRenderer(port);
+await client.ready;
+await client.send('Page.enable');
+await client.send('Page.addScriptToEvaluateOnNewDocument', {
+  source: readFileSync(join(here, 'react-commit-probe.js'), 'utf8'),
+});
+await client.send('Page.reload');
+await sleep(BOOT_MS);
+
+await clickSessionRow(client, 1);
+await sleep(3000);
+
+const results = [];
+for (const row of ROWS) {
+  await client.evaluate(
+    '__MAKA_PROBE__.commits.length = 0; __MAKA_PROBE__.dispatches.length = 0; __MAKA_PROBE__.arm = true',
+  );
+  await clickSessionRow(client, row);
+  await sleep(SETTLE_MS);
+  await client.evaluate('__MAKA_PROBE__.arm = false');
+  const { commits } = JSON.parse(
+    await client.evaluate('JSON.stringify({ commits: __MAKA_PROBE__.commits })'),
+  );
+  const fullTree = commits.filter((commit) => commit.total > FULL_TREE_FIBERS);
+  const renderRoots = {};
+  for (const commit of fullTree) {
+    const name = commit.rr[0]?.name ?? '?';
+    renderRoots[name] = (renderRoots[name] ?? 0) + 1;
+  }
+  const rendered = commits.reduce((sum, commit) => sum + commit.total, 0);
+  results.push({ row, commits: commits.length, fullTree: fullTree.length, renderRoots, rendered });
+  console.log(
+    `row ${row}: commits=${commits.length} fullTree=${fullTree.length} renderedFibers=${rendered} roots=${JSON.stringify(renderRoots)}`,
+  );
+}
+
+console.log(
+  `\n[${label}] median commits=${median(results.map((r) => r.commits))} ` +
+    `fullTree=${median(results.map((r) => r.fullTree))} ` +
+    `renderedFibers=${median(results.map((r) => r.rendered))}`,
+);
+writeFileSync(`session-switch-commits-${label}.json`, `${JSON.stringify(results, null, 1)}\n`);
+client.close();


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org