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.
| Piece | Role | Lives |
|---|---|---|
| AgentDockMount | The single Dock, mounted in the (workspace) layout. Resolves the surface, binds the thread, renders the substrate AgentDock. | src/components/agents/ |
| agent-thread-store | Module-scoped registry of threads. Idempotent merge-by-id, thread-only sessionStorage, identity purge. | src/lib/agents/ (outside React) |
| useAgentThread | A thin useSyncExternalStore subscriber — same shape as useAtlasRail. | src/lib/agents/ |
| resolveAgentSurface | Pathname → { hasAgent, surfaceKey, entityId }. L1 share one key; entities get their own; no-agent parks. | src/lib/agents/ |
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.
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.
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
src/lib/agents/agent-thread-store.ts— module-scoped thread registry; idempotent merge-by-id; thread-onlysessionStorage(capped, versioned); identity purge.src/lib/agents/agent-surface.ts— pathname → surface (L1 shared key · entity key + id · parked).src/lib/agents/use-agent-thread.ts—useSyncExternalStoresubscriber.src/components/agents/agent-dock-mount.tsx— the layout-mounted Dock; resolves surface, binds thread, parks no-agent routes, reconciles identity.src/components/agents/atlas-descriptor.ts— Atlas descriptor promoted out of the fixtures file (re-exported there) so the app bundle doesn't pull sample JSX.(workspace)/layout.tsx— mountsAgentDockMountwith/app/meidentity in theokbranch.Agents.dockGreetingadded tomessages/en.json+messages/hi.json(both locales).- Unit tests for the store and the resolver; no color literals, no hardcoded user-facing copy, tokens only.
Files changed
12 file(s), +888 / -16 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +1 | -0 |
| messages/hi.json | Config | +1 | -0 |
| src/app/[locale]/(workspace)/layout.tsx | App route | +19 | -0 |
| src/components/agents/agent-dock-mount.tsx | Component | +168 | -0 |
| src/components/agents/agents.fixtures.tsx | Component | +5 | -16 |
| src/components/agents/atlas-descriptor.ts | Component | +27 | -0 |
| src/components/agents/index.ts | Component | +2 | -0 |
| src/lib/agents/__tests__/agent-surface.test.ts | Library | +60 | -0 |
| src/lib/agents/__tests__/agent-thread-store.test.ts | Library | +159 | -0 |
| src/lib/agents/agent-surface.ts | Library | +88 | -0 |
| src/lib/agents/agent-thread-store.ts | Library | +308 | -0 |
| src/lib/agents/use-agent-thread.ts | Library | +50 | -0 |