Counsel keeps more than one conversation with Atlas — and names them without leaking a single privileged word
RFD-AGT-002 D-12 makes the session the container for an
agent conversation: scoped per-user + per-agent, resumable, and —
the legally load-bearing part — scope-safe in its title.
This Story is the app-side b/2: the
new / resume / switch dropdown in the Atlas chat header,
plus the title rule that a session is only ever a generic
"Session 3" or a name the user typed — never a client or matter name
scraped out of the messages. The durable session truth (the server-minted
id, re-inject-on-surface-change, resume-revalidate, switch-scope-drop)
is the sibling a/2 in pramaan-agents
(Story-1720);
this Story is the client UI that drives the picker.
🛰️ Satellite — why a dropdown of conversation titles is a confidentiality surface
The stakes are privilege, not convenience. The moment a product lets counsel keep several Atlas conversations, it has to label them — and a label is metadata that shows in a dropdown, survives a refresh, and sits in browser storage. Every other chat product titles a thread by auto-summarising its first message. For a legal product that is a leak: "Acme Corp v. Meridian — settlement strategy" auto-extracted into a dropdown is privileged content sitting in plain sight, readable over a shoulder, copyable by anything with storage access.
The naive fix — "title the session from its content like everyone else" — detonates exactly where it must not: it manufactures privileged metadata as a side effect of a convenience feature. So the title rule is inverted and made structural: a session title can come from only two places — a generic ordinal the store mints, or a string the user deliberately types. The component that renders the picker has no access to thread messages at all, so a content-derived title is not merely discouraged, it is unreachable.
✈️ Airplane — the shape of a session
One sustained analogy: a session is a labelled folder, and the per-surface threads from Story-1655 are the loose pages inside it. Switching folders swaps every page you see; opening a new folder gives you a blank set everywhere; the folder's tab label is either a pre-printed number or whatever you write on it by hand — you never let the contents bleed onto the tab.
Concretely, the active session id is folded into the existing thread key:
buildThreadKey →
userId:firmId:env:agentKey[:sessionId]:surfaceKey[:entityId]
The sessionId segment is the only new thing. Because the thread
store already caches one conversation per surface keyed by this string,
inserting the session id means switching the session swaps the
whole set of per-surface threads and "New session" lands
on blank threads everywhere — the picker genuinely works
end-to-end, against the real per-tab thread cache, with no dead
placeholder. (Omit the id and the key keeps its legacy shape, so existing
threads and tests are untouched.)
🔬 Microscope — the four moving parts
1 · The store that can only hold a scope-safe title
src/lib/agents/agent-session-store.ts — module-scoped registry
+ useSyncExternalStore, mirroring agent-thread-store.ts.
A session is { id, title: string | null, ordinal, createdAt }.
The title is null until a user renames it; the picker renders
null as the localized generic Session {n}.
The only mutation that can set a title is
renameSession(id, userText) — and a blank value reverts to
null. The store never reads a thread message, so a title is
structurally incapable of carrying privileged content. The list is
per-user + per-agent (buildSessionScopeKey), persisted per-tab
in sessionStorage with a versioned envelope (HR-23), and
purged on identity change.
2 · The hook
src/lib/agents/use-agent-sessions.ts
A thin useSyncExternalStore subscriber — the same pattern as
useAgentThread — exposing sessions,
activeSessionId, and create / switchTo / rename.
It owns no state.
3 · The dropdown
src/components/agents/session-menu.tsx (presentational)
Reuses the shared DropdownMenu primitive: the trigger shows the
active session's scope-safe label; the menu lists every session (with a
check on the active one) to resume / switch, a
New session action, and a Rename action
that opens a small Dialog with an Input. The
rename field is pre-filled with the user's own title only — never
the generic default — so a generic session stays generic unless the user
deliberately names it. All copy is next-intl (en + hi);
colours are dark-header tokens. It takes data + callbacks as props, so it
verifies in Storybook (session-menu.stories.tsx) without Clerk
or a gateway.
4 · The wiring
src/components/agents/agent-dock-mount.tsx
The Dock mount binds the session list for the Atlas scope, folds
activeSessionId into the thread key, renders
SessionMenu in the new headerSlot of the ACP
header (threaded through AgentDock →
AgentChatPanel), and reconciles the session list alongside the
thread list when the identity fingerprint changes.
Acceptance criteria → evidence
| Criterion | Where | State |
|---|---|---|
| New / resume / switch session UI works | session-menu.tsx + agent-dock-mount.tsx (active id → thread key, so switching genuinely swaps the visible threads) |
done |
| Titles are scope-safe (no auto-extracted privileged names) | Store has no access to messages; title only via generic default or explicit rename — proven by agent-session-store.test.ts "scope-safe titles" |
done |
| Story walkthrough attached | this file | done |
Scope boundary — what this Story is not
session_id,
re-inject-on-surface-change (don't fork), resume-revalidate the Page Card,
and switch-scope-drop of matter context — are the sibling a/2
(Story-1720,
pramaan-agents) and AgentCore Memory (D-13). This app Story is
the client-side UI cache that drives the picker; it holds no authority and
mints no durable id. When the container lands, this UI binds to the
server's session ids in place of the local ones — the dropdown, the
scope-safe title rule, and the thread-key namespacing stay as-is.
Verification
pnpm test— 795 passed (incl. 13 new inagent-session-store.test.ts; existingagent-thread-store.test.tsgreen after the optionalsessionIdkey change).tsc --noEmit— clean.eslint— clean on all changed files.pnpm build— green.- i18n parity — every new
Agents.*key present in bothen.jsonandhi.json. - Storybook —
SessionMenuDefault / SingleGenericSession / Live stories render the picker on the real dark ACP-header surface.
Files changed
12 file(s), +846 / -24 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +10 | -1 |
| messages/hi.json | Config | +10 | -1 |
| src/components/agents/agent-chat-panel.tsx | Component | +14 | -8 |
| src/components/agents/agent-dock-mount.tsx | Component | +30 | -5 |
| src/components/agents/agent-dock.tsx | Component | +4 | -0 |
| src/components/agents/index.ts | Component | +1 | -0 |
| src/components/agents/session-menu.stories.tsx | Component | +86 | -0 |
| src/components/agents/session-menu.tsx | Component | +162 | -0 |
| src/lib/agents/__tests__/agent-session-store.test.ts | Library | +173 | -0 |
| src/lib/agents/agent-session-store.ts | Library | +296 | -0 |
| src/lib/agents/agent-thread-store.ts | Library | +10 | -9 |
| src/lib/agents/use-agent-sessions.ts | Library | +50 | -0 |