PR: #421: feat(agents): session new/resume/switch dropdown + scope-safe titles [Story-1721] by @ankitmaheshwari-com
View PR →
Story 1721 · Epic-1646 Agentic interaction · RFD-AGT-002 D-12 · app b/2 of the Sessions pair

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

CriterionWhereState
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

The durable session semantics — the server-minted 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

Files changed

12 file(s), +846 / -24 lines

Path Role + -
messages/en.jsonConfig+10-1
messages/hi.jsonConfig+10-1
src/components/agents/agent-chat-panel.tsxComponent+14-8
src/components/agents/agent-dock-mount.tsxComponent+30-5
src/components/agents/agent-dock.tsxComponent+4-0
src/components/agents/index.tsComponent+1-0
src/components/agents/session-menu.stories.tsxComponent+86-0
src/components/agents/session-menu.tsxComponent+162-0
src/lib/agents/__tests__/agent-session-store.test.tsLibrary+173-0
src/lib/agents/agent-session-store.tsLibrary+296-0
src/lib/agents/agent-thread-store.tsLibrary+10-9
src/lib/agents/use-agent-sessions.tsLibrary+50-0