PR: #397: feat(agents): agent-agnostic substrate UI primitives (APB/ACP/Composer/Dock) [Story-1649] by @ankitmaheshwari-com
View PR →
Story 1649 · Epic-1646 Agentic interaction · Wave-1 (visual layer)

One substrate, any agent — the chat surface that isn't Atlas

PRAMAAN is about to grow more than one agent: Atlas on the boards, a scoped specialist on a matter, a ledger agent on the register. This Story builds the agent-substrate UI primitives — the presence pill, the chat panel, the composer, and the dock that orchestrates them — as agent-agnostic components in src/components/agents/, matching the locked Claude-Design ACP treatment. Atlas is the demo descriptor, never hardcoded into the substrate. Swap the descriptor → a different agent's bar, panel, and composer, with zero UI rewrite.

🛰️ Satellite — why the substrate must not know Atlas's name

The stakes: if the first agent surface is built around Atlas — "Atlas" in the type names, "can help you start a matter" in the markup, one reasoning ladder baked in — then the second agent is a copy-paste fork, and the third is three forks drifting apart. The whole product bet is many agents across many surfaces; a per-agent UI is the thing that makes that bet unaffordable.

The naive fix — "we'll generalize it later" — detonates on the first concrete decision. A thread that renders agent prose with a bespoke <div> per message can't grow a new message kind without touching every call site; a composer with a hardcoded model dropdown leaks the cost lever onto counsel's surface; a reasoning control with an "Auto" option hands the judgement back to the machine. The real design pushes every agent-specific fact into one descriptor and makes the thread a discriminated union so a new message kind is additive, not a rewrite. The substrate renders structure; the descriptor supplies identity; the caller supplies data.

✈️ Airplane — five primitives and a descriptor

One analogy: the descriptor is a name badge + capability card you clip onto a generic chat shell. The shell never changes; the badge decides who is wearing it.

PrimitiveRoleDriven by
AgentDescriptorThe single source of identity + abilities (key, name, avatarSrc, accent, presenceKey, capabilities, bio).—
AgentPresenceBar (APB)The always-visible pill: avatar + accent name + status dot + open affordance.descriptor + rail status
AgentChatPanel (ACP)White surface, dark identity header, thread of typed item kinds.descriptor + AgentThreadItem[]
AgentComposerAuto-growing input · paperclip attach (no "+") · reasoning pill · Send.descriptor capabilities
AgentDockOrchestrator: controlled open, swaps APB ↔ ACP, mounts the composer.caller
lucide icons, no emoji. Tokens only. Every glyph is a lucide stroke icon; every colour is a semantic token (--pill-bg, --presence-green/amber/red, --shadow-atlas-*) or a token-backed utility. No hex/rgb/hsl literal anywhere in src/components/agents/ (verified by the AGENTS.md grep).

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

The descriptor — identity is data, not code

agent-descriptor.ts is the whole reason the substrate is agnostic. accent is a token reference ("var(--brand-primary)" for Atlas) used for the name colour and avatar ring — identity, never status. presenceKey is an i18n key, not a literal, so the "can help…" line follows the page and the locale (the en+hi doctrine survives first ship). capabilities.reasoningLevels is what the reasoning pill renders — different agents expose different sets.

The thread — a discriminated union, so kinds are additive

agent-thread-item.ts defines AgentThreadItem as a union of eight kinds: agentText (plain sans, no bubble) · counselNote (mono, faint warm-tint block) · toolAction (quiet inline note) · navMarker · handoff · timeMarker ("thought Ns") · proposal (the one card) · thinking. The renderer is a single switch — adding a ninth kind is one new case, and every existing kind is untouched. The mockup's atlasText is renamed agentText on purpose: a union member named after one agent would bake Atlas into the substrate type, contradicting the entire Story.

Structured content stays in the app's primitives. An agentText item carries a ReactNode, so a table is rendered by the app's Table primitive (markdown/MDX → components) inside it — the panel never builds bespoke HTML tables. The fixtures prove this with a real <Table> result.

The reasoning pill — "how hard," tier hints only, no Auto

reasoning-pill.tsx renders exactly the levels the active descriptor resolves to (Low / Medium / High / Max / Ultra Max for Atlas), each with a lucide signal-bar icon and a plain tier hint ("Fastest", "Deeper analysis"). There is no Auto and no model, provider, or price on the surface — the only extra copy allowed is a tier hint. The level union is a local STUB; when the functions contract is codegen'd into src/gen it collapses onto the generated enum (HR-22). The comment says so in the file so the seam is impossible to miss.

The composer — decluttered to a paperclip

agent-composer.tsx is the locked Claude-Design treatment: an auto-growing textarea over one control row — a paperclip that opens a file picker (v1: attach files only, no "+" menu), the reasoning pill, and a round Send. Staged files show as removable chips. It is controlled and presentational; the Dock owns transport. (This is the new agnostic composer; the richer ADR-129 posture composer in agent-controls/ is a separate, earlier surface.)

The APB and the avatar — identity at a glance

agent-presence-bar.tsx is the front door: a cream pill with the agent name in its accent colour, a presence suffix, and a status dot driven by the rail. On thinking/error the suffix swaps to localized chrome; on ready it shows the per-page presence data. agent-avatar.tsx generalizes the Atlas avatar: it renders the descriptor's image grayscale (AI doctrine) and falls back to an abstract token-built disc when no image is set, with the presence-token status dot and an optional accent ring.

The Dock — one orchestrator, one visible at a time

agent-dock.tsx holds the controlled open and swaps the APB (closed) for the ACP (open) — never both. It resolves the open/close chrome labels and wires APB → open, ACP → close, mounting the composer into the ACP footer slot.

Verified in Storybook

Every primitive ships a co-located *.stories.tsx with the required states: APB (ready / thinking / error + a different descriptor), ACP (empty / streaming / populated / error), Composer (empty / with-attachment / reasoning-open), the reasoning pill (closed / open / limited levels), the avatar (every status + ring + fallback), and the Dock (closed → APB, open → ACP). Components resolve their own chrome via next-intl; the Storybook preview already provides the NextIntlClientProvider, so the stories render deterministically without Clerk or a gateway.

Live-data seam (named, not hidden). This Story is the visual layer only. The live rail (real status + streaming thread), the layout-level mount + portal, and drag-to-resize are stubbed — status, items, and the composer arrive as props. Wiring onSend → the agent invoke contract, resolving the generated reasoning-level enum, and the route registry that resolves a descriptor per surface are follow-up children of Epic-1646 (the contract + rail children). Per the Story Process, the Project #2 board was not touched.

What shipped

Files changed

18 file(s), +1502 / -8 lines

Path Role + -
messages/en.jsonConfig+49-4
messages/hi.jsonConfig+49-4
src/components/agents/agent-avatar.stories.tsxComponent+26-0
src/components/agents/agent-avatar.tsxComponent+72-0
src/components/agents/agent-chat-panel.stories.tsxComponent+70-0
src/components/agents/agent-chat-panel.tsxComponent+203-0
src/components/agents/agent-composer.stories.tsxComponent+90-0
src/components/agents/agent-composer.tsxComponent+154-0
src/components/agents/agent-descriptor.tsComponent+81-0
src/components/agents/agent-dock.stories.tsxComponent+63-0
src/components/agents/agent-dock.tsxComponent+87-0
src/components/agents/agent-presence-bar.stories.tsxComponent+42-0
src/components/agents/agent-presence-bar.tsxComponent+102-0
src/components/agents/agent-thread-item.tsComponent+94-0
src/components/agents/agents.fixtures.tsxComponent+152-0
src/components/agents/index.tsComponent+19-0
src/components/agents/reasoning-pill.stories.tsxComponent+36-0
src/components/agents/reasoning-pill.tsxComponent+113-0