PR: #399: feat(agents): Agent Dock at workspace layout + per-tab session thread store [Story-1655] by @ankitmaheshwari-com
View PR →
Story 1655 · Epic-1646 Agentic interaction · Wave-1 (continuity layer)

The chat that doesn't die when you change pages

Story-1649 built the agent substrate (presence pill, panel, composer, dock) as props-in primitives. This Story gives them a home and a memory: the Agent Dock is mounted once at the authenticated workspace layout.tsx, and the conversation lives in a module-scoped, per-tab thread store outside the React tree. Move matters → clients → settings and one Atlas follows you, thread intact; open a specific matter and you get that matter's private thread; refresh and the conversation comes back. No agent on a page? The Dock parks — it hides without throwing the conversation away.

🛰️ Satellite — why mounting matters more than rendering

The stakes: an agent you have to re-explain yourself to on every navigation is not an assistant, it is a form. If the Dock is mounted per page, every route change unmounts it — React tears down the component, the thread state in useState evaporates, the live run id is gone, and counsel starts over. The whole bet of "one Atlas that follows you across the product" collapses the first time the user clicks a nav item.

The naive fix — "lift the thread into React Context / a provider at the layout" — detonates on the page remount. The layout persists, yes, but the page beneath it still remounts on navigation, and any state that a page (or a StrictMode double-invoke) can touch is state that can be reset at the worst moment. We have already paid for this lesson once: the Atlas rail's WebSocket used to live inside a component and a remount silently killed the in-flight run (Story-1373 / #1372). The real design puts the durable thing outside the React tree entirely — a module-scoped registry keyed by identity and surface — so a remount re-subscribes to the same thread instead of recreating it. The component is a window onto the store, never its owner.

✈️ Airplane — one mount, one store, a key per surface

One analogy: the thread store is a coat-check. The tab is the cloakroom (one per browser tab); each conversation hangs on a numbered hook; the number is built from who you are and where you are. Walk to a new room (route) and you either get the same coat back (L1 surfaces share one hook) or a different private coat (each matter has its own). Log out and the whole cloakroom is emptied so the next person never finds your coat.

PieceRoleLives
AgentDockMountThe single Dock, mounted in the (workspace) layout. Resolves the surface, binds the thread, renders the substrate AgentDock.src/components/agents/
agent-thread-storeModule-scoped registry of threads. Idempotent merge-by-id, thread-only sessionStorage, identity purge.src/lib/agents/ (outside React)
useAgentThreadA thin useSyncExternalStore subscriber — same shape as useAtlasRail.src/lib/agents/
resolveAgentSurfacePathname → { hasAgent, surfaceKey, entityId }. L1 share one key; entities get their own; no-agent parks.src/lib/agents/
The key IS the isolation boundary. Every thread is keyed userId:firmId:env:agentKey:surfaceKey[:entityId] — userId first, so a thread is private by construction. Two counsel in the same firm, even on the same matter, can never resolve to each other's thread (RFC-APB-003 §Privacy, LOCKED).

🔬 Microscopic — the pieces, and why each is shaped the way it is

Mount at the layout — fat layout, thin page

(workspace)/layout.tsx already owns the chrome and the /app/me fetch; the Dock joins it. In the ok branch the layout passes { userId, firmId, impersonating } to <AgentDockMount>, rendered as a sibling of AppChrome inside the existing QueryProvider. Because it is in the layout, navigating between Level-1 routes never unmounts it — the open/closed panel state (plain useState in the mount) survives, because the mount itself survives.

The store — module-scoped, the Story-1373 pattern

agent-thread-store.ts is a Map of AgentThread instances at module scope — one JS realm = one browser tab. Each thread exposes the subscribe / getSnapshot / getServerSnapshot triple that useSyncExternalStore wants, mirroring atlas-rail-store.ts exactly. A page remount under the layout-mounted Dock calls getAgentThread(key) and gets the same instance back — the thread is re-bound, not rebuilt.

Idempotent merge-by-id — replay can't duplicate a turn

appendMessages merges by stable id: an unseen id is appended; a seen id is replaced in place; a byte-identical re-apply is a true no-op (it neither rewrites the array nor notifies subscribers). This is what makes refresh replay safe — re-hydrating the persisted thread, or a broker redelivering a frame, can never double-render an assistant turn (RFC §discussion, AGENT2 #7).

sessionStorage — a per-tab cache, thread-only

On mutation the thread persists a tiny envelope — { v, messages, activeRunId } — to sessionStorage, capped at the most-recent 200 messages. sessionStorage is per-tab, so it is the per-tab session boundary: no synthetic session id is needed in the key, and a duplicated tab diverges on its own. The activeRunId rides along (an identifier, HR-23-safe) so a refresh mid-stream still has a run to reconnect/replay (RFC §discussion, AGENT4 #2). What is never persisted: tools, CPC, authz claims, raw records, attachments, or any ReactNode / callback — the stored message union is a narrow, serializable subset of the substrate's AgentThreadItem.

Live-only kinds stay live. A proposal (has callbacks), an agentText with a rendered table, the thinking indicator — none are serializable, so none are stored. They are re-derived by the live rail. The store holds plain data (counselNote, agentText markdown, navMarker, handoff, timeMarker).

The surface resolver — hybrid continuity from the URL

resolveAgentSurface(pathname) implements the RFC's hybrid (§B): every Level-1 surface maps to the one shared l1 key (one continuous Atlas), a matters/<id> or clients/<id> route maps to a private matter-detail / client-detail key + the canonical entityId, and an entity sub-section (matters/M-5/chronology) stays on the same entity thread (a section is an anchor, not a new chat — AGENT2 #5). account and help are no-agent: the Dock parks. The default for any unlisted route is "agent present (L1)", so a new route gets Atlas automatically instead of silently losing the chat. The canonical long-term mechanism is RFC-APB-002 surface registration — a later ticket; this static map is the interim source of truth and says so.

Clearing on identity change — privacy is enforced, not hoped for

The mount runs reconcileAgentThreadIdentity in an effect keyed on userId:firmId:env:impersonation. A plain refresh keeps the same fingerprint → threads survive. A logout→login as a different user, a firm switch, or entering/leaving an impersonation (support on-duty) session changes the fingerprint → every persisted thread and the in-memory registry are purged. Because the key already carries userId and firmId, the next session could not read the old threads anyway; the purge makes the privacy guarantee belt-and-braces.

Parking — hide the surface, keep the store

On a no-agent route the mount returns null after its hooks run (hook order stays stable across navigation). The stores are untouched, so navigating away from settings back to the board restores the L1 thread exactly as it was (issue AC; AGENT2 Q5).

Verified

Pure logic is unit-tested: agent-surface.test.ts (L1 share, locale strip, entity + sub-section, parking) and agent-thread-store.test.ts (merge-by-id idempotency, replace-in-place, notify-on-real-change, hydrate-after-registry-drop = the refresh/remount, clear, persisted-envelope shape, identity purge on user/firm/impersonation change, same-fingerprint keep). pnpm lint, pnpm test (682 passing), and pnpm build are green.

Live-rail seam (named, not hidden). This Story is the mount + continuity layer. The composer currently appends the counsel's own message to the thread — enough to prove the thread persists across navigation and refresh — and the agent's reply, real streaming status, run-id lifecycle, and rich-markdown rendering arrive with the runtime-rail child of Epic-1646 (broker rail #356). No pnpm codegen was run; the Project #2 board was not touched.

What shipped

Files changed

12 file(s), +888 / -16 lines

Path Role + -
messages/en.jsonConfig+1-0
messages/hi.jsonConfig+1-0
src/app/[locale]/(workspace)/layout.tsxApp route+19-0
src/components/agents/agent-dock-mount.tsxComponent+168-0
src/components/agents/agents.fixtures.tsxComponent+5-16
src/components/agents/atlas-descriptor.tsComponent+27-0
src/components/agents/index.tsComponent+2-0
src/lib/agents/__tests__/agent-surface.test.tsLibrary+60-0
src/lib/agents/__tests__/agent-thread-store.test.tsLibrary+159-0
src/lib/agents/agent-surface.tsLibrary+88-0
src/lib/agents/agent-thread-store.tsLibrary+308-0
src/lib/agents/use-agent-thread.tsLibrary+50-0