23 file(s), +1012 / -19 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +24 | -2 |
| messages/hi.json | Config | +24 | -2 |
| src/app/[locale]/(workspace)/settings/firm/channels/page.tsx | App route | +3 | -3 |
| src/app/[locale]/(workspace)/settings/firm/channels/register/page.tsx | App route | +78 | -0 |
| src/app/api/internal/channels/register/route.ts | App route | +11 | -0 |
| src/components/settings/channels/channel-refusal-summary.stories.tsx | Component | +38 | -0 |
| src/components/settings/channels/channel-refusal-summary.tsx | Component | +64 | -0 |
| src/components/settings/channels/channel-register-page.stories.tsx | Component | +61 | -0 |
| src/components/settings/channels/channel-register-page.tsx | Component | +64 | -0 |
| src/components/settings/channels/channel-register-row.tsx | Component | +89 | -0 |
| src/components/settings/channels/channel-register-table.stories.tsx | Component | +53 | -0 |
| src/components/settings/channels/channel-register-table.tsx | Component | +65 | -0 |
| src/components/settings/channels/channels-fixtures.ts | Component | +63 | -1 |
| src/components/settings/channels/channels-page.tsx | Component | +17 | -1 |
| src/components/settings/channels/presentation.ts | Component | +30 | -0 |
| src/lib/agents/atlas-rail-store.ts | Library | +32 | -0 |
| src/lib/agents/thread-refresh.test.ts | Library | +115 | -0 |
| src/lib/agents/thread-refresh.ts | Library | +72 | -0 |
| src/lib/agents/use-mira-conversation-rail.ts | Library | +48 | -8 |
| src/lib/channels/gateway.ts | Library | +42 | -0 |
| src/lib/kubb-client.ts | Library | +4 | -0 |
| src/lib/surface-registry/page-card/catalog.ts | Library | +3 | -0 |
| src/lib/surface-registry/route-registry.ts | Library | +12 | -2 |
RFD-AGT-013 (T2m-b) ships the two app-side halves that surface the
W2 backend work to people. Half one (CC-50): a firm admin opens
Settings → Channels → Firm register
(/settings/firm/channels/register) and sees every channel binding in the
firm — who it belongs to, its masked number, lifecycle status, and a
metadata-only activity rollup — plus the firm's refused-attempt summary.
This visibility is part of the compensating stack that justified skipping step-up
auth. Half two (CC-32): when a counsel talks to Mira on WhatsApp
while the matter is open in the app, the open thread now catches up live — the app
consumes the thread.updated refresh notice (functions Story-2880) and
re-reads the canonical turn store, so the app always shows the whole conversation.
The register is a security surface, not a transcript: counts and timestamps only, subjects always masked, message content never crosses the wire (enforced backend-side in Story-2879 and asserted again here by the generated Zod contract).
| Layer | What landed |
|---|---|
| Gateway (HR-21) | src/lib/channels/gateway.ts — getRegister() +
getRegisterWithResponseHeaders(): fetch as unknown,
assertContract(channelRegisterResponseSchema, …) at the trust boundary. |
| BFF proxy | src/app/api/internal/channels/register/route.ts (GET) +
the toBrowserRoute mapping in src/lib/kubb-client.ts, so the
generated useAppGetChannelRegister hook resolves in the browser. |
| Route (RSC) | …/settings/firm/channels/register/page.tsx — forum-pattern
server-prefetch → HydrationBoundary → client page (ADR-095). Mirrors the
backend's require_firm_admin gate with isFirmAdmin(me) so the
UI and the 403 can never disagree; non-admins get an honest, localized notice.
Prefetch failure degrades to CatalogLoadError (Bug-1781).
agentSurface exported; route registry + page-card catalog regenerated. |
| Components | src/components/settings/channels/:
channel-register-page.tsx (orchestrator, 64 lines),
channel-register-table.tsx + channel-register-row.tsx
(forums-table chrome: shared grid, header, hover rows; member avatar + real photo
with initials fallback via the shared Avatar),
channel-refusal-summary.tsx (total, last attempt, per-kind chips —
unknown kinds render their raw wire identifier, never hidden). Each with
co-located stories incl. the empty first-run state. |
| Member join | The register carries userId only; the page joins it to
useAppListMembers (same query params as the Team page, one shared cache
entry) for name + photo. A binding whose member left the firm shows
“Former member” — the row never disappears. |
| Entry point | The counsel-facing Channels page header gets a “Firm register” action —
rendered only for firm admins (showRegisterLink from the RSC's
me). |
{"type":"thread.updated"} — routing keys only
(chat_session_id, matter_id, agent_key), never
turn bodies — onto the user's newest live app socket (functions Story-2880).src/lib/agents/thread-refresh.ts — tolerant frame
parser + a module-scoped bus. The frame can arrive on ANY rail's socket (wizard, dock,
Mira), so AgentRailConnection.handleFrame broadcasts it instead of touching
that connection's own rail state.useMiraConversationRail subscribes; on a notice for its
matter it invalidates the session-list and session-turns queries (the channel may even
have created the session) and re-reads
GET /app/agent-sessions/{id}/turns — the canonical store, per the
Story-2880 contract.AgentRailConnection.refreshThread then replaces the
in-memory thread with the (longer) durable thread. Two guards keep live truth safe: a
run in flight on this rail wins (its own settle carries the turn), and a server thread
with nothing new applies nothing. The once-per-session hydrateThread seed
is unchanged for the non-refresh path.src/lib/agents/thread-refresh.test.ts — 7 tests: frame parse
(well-formed / wrong type / malformed / non-JSON), bus subscribe-unsubscribe,
refresh grows an already-hydrated thread, no-op on nothing-new, refuses to clobber a
streaming run, and the store's socket path broadcasts the frame.pnpm lint, typecheck, test
(255 files / 1582 tests), build, check:routes,
check:catalog, storybook:drift — all green. Token scan and
hardcoded-copy scan clean; every new key in BOTH en.json and
hi.json.
Fixtures are not evidence the shipped path is wired: the register's live proof needs a
staging firm with a real bound number (UAT M-01 ceremony), and the thread-refresh live
proof needs the channel-ingress consumer running so a real WhatsApp turn fires the
thread.updated frame end-to-end. Both are the human UAT cards attached to
EPIC-2834 W3 — this story's issue stays in Review until that evidence lands.