* fix(infra): release builds all 17 registry images; pg_dump backup sidecar release.yml was missing roboco-agent-grok-prompter and roboco-agent-grok-secretary (both FROM the bare local roboco-agent-grok tag, so agent-grok now builds explicitly ahead of the loop, mirroring the agent-base special case) — a fresh registry pull could never succeed. Both compose files gain a backup sidecar on the data network: pg_dump -Fc on start and every 24h, crash-safe tmp+rename, newest-14 rotation, restore walkthrough in docs/backend/ops/database-backups.md. * chore(docs): reflow hard-wrapped prose inherited from the six-PR merge train * chore(foundation): regenerate lifecycle artifacts; reflow inherited prose --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
19 KiB
Conversation-first layout, agent identity, and live-stream affordances
Interaction spec for the pattern that makes a live conversation the primary surface of a view, rather than a secondary panel bolted onto a data table: a three-region layout, a team-color agent identity scheme that scales to the full 22-agent roster, connection-state visual treatment, a new-message arrival cue, and the loading/empty/error states a conversation panel needs. Written so a frontend developer can implement directly from this document without further design clarification.
Scope and where this lives
This is a pattern spec, not a new page proposal. It extends the one conversation surface RoboCo already ships, panel/src/app/(dashboard)/a2a/page.tsx, plus its sub-components:
| Piece | Existing file it extends |
|---|---|
| Layout | panel/src/app/(dashboard)/a2a/page.tsx (currently a two-pane grid-cols-12 layout) |
| Roster / list rail | a2a-switchboard.tsx, a2a-pair-card.tsx, a2a-conversation-list.tsx |
| Message stream | a2a-transcript.tsx |
| Agent identity | panel/src/lib/agent-utils.ts (getAgentInitials, getAgentDisplayName) |
| Connection state | panel/src/hooks/use-websocket.ts (ConnectionState = "connecting" | "connected" | "reconnecting" | "disconnected"), panel/src/components/layout/connection-status.tsx |
Nothing here replaces the /a2a page's existing behavior (message fetch, reply composer, switchboard/list toggle) — every section below is additive: a third pane, a color layer on an existing avatar, a refined connection badge, an entrance transition for new rows, and the states around the stream when it has nothing (yet) to show. The same three-region composition and identity/connection/arrival treatment apply to any future conversation surface RoboCo adds (e.g. a unified agent-activity inbox) without re-deriving the pattern.
Design bar dial read: dense product UI (a data-heavy live-ops surface inside existing panel chrome), not a landing page — variance 2, motion 2-3, density 7, the UX/UI cell's dashboard default. No new radius or shadow tokens; color additions are a bounded, named palette (below), not ad hoc hex values.
1. Conversation-first layout
The three regions
A conversation-first surface is composed of three regions, always in this order left-to-right, with the stream always the widest:
┌─ Roster (list rail) ─┬───── Stream (primary) ─────┬─ Context (collapsible) ─┐
│ conversation/agent │ message-by-message, │ participant summary, │
│ list, search/filter, │ oldest → newest, the │ linked task, quick │
│ activity indicator │ widest region — this is │ actions │
│ per row │ what the user came for │ │
└────────────────────────┴───────────────────────────────┴─────────────────────┘
- Roster is navigation: "which conversation am I looking at" — today's
A2ASwitchboard/A2AConversationList. - Stream is content: "what was said" — today's
A2ATranscriptplus the reply composer beneath it. This region gets the majority of horizontal space at every breakpoint that shows more than one region, because it is the primary surface, not a byproduct of the roster selection. - Context is metadata: participant identity detail, the linked task (title, status, a link into
/tasks/{id}), and any quick actions — a new region, collapsible, not present in the current implementation.
Grid and breakpoints
Extends the existing grid grid-cols-12 gap-4 lg:gap-6 container (a2a/page.tsx:260) with one more breakpoint tier rather than replacing it:
| Breakpoint | Regions visible | Column split |
|---|---|---|
< lg (mobile/tablet) |
One region at a time, drill-in with the existing ArrowLeft back button (a2a/page.tsx:248-258) |
col-span-12 |
lg – < xl |
Roster + Stream (today's behavior, unchanged) | Roster col-span-4, Stream col-span-8 |
xl+ |
Roster + Stream + Context | Roster col-span-3, Stream col-span-6, Context col-span-3 |
The context pane is the new addition and is the one that collapses first — it never appears below xl, and even at xl+ it is dismissible via a header toggle (a PanelRightClose/PanelRightOpen icon button, size="sm" variant="ghost", matching the existing switchboard/list toggle buttons at a2a/page.tsx:275-296) so a user who wants the stream at full width above xl can still get it. Collapsed state persists in localStorage (roboco:conversation-context-open, boolean), read once at mount — the same persistence idiom already used for panel-width/theme preferences (avoids a new state-management dependency).
Context pane content
When open, the context pane shows, top to bottom:
- Both participants' identity cards (avatar + name + team badge — see §2), each linking to
/agents/{slug}. - The linked task, if any: title (truncated to one line), status
Badge(reusing the samevariantmapping already used ata2a/page.tsx:337-344), and a "View task" link. - A muted one-line hint when there is no linked task ("This conversation has no linked task"), matching the tone of the existing no-task composer message (
a2a/page.tsx:373-377).
The context pane does not duplicate the reply composer or transcript — it is read-only summary, never a second place to act on the conversation.
2. Agent identity affordance
Why team color, not per-agent color
With 22 agents in the roster (AGENT_UUIDS in agent-utils.ts), a unique hue per agent is not legible — nobody can hold 22 arbitrary colors in working memory, and two similar hues (e.g. two blues for be-dev-1 and fe-dev-1) would read as "the same agent" at a glance. Colour is scoped to the axis that actually matters for fast scanning — which cell this agent belongs to — and individual identity within a team is carried by the existing initials/code, not a second hue. This scales cleanly: adding a 23rd agent to an existing team changes zero colors; adding a whole new team is the only case that needs a new bucket, and the palette below already has headroom.
The six buckets
A new pure function, getAgentTeamColor(agentId: string | null | undefined): AgentTeamColor, colocated in agent-utils.ts next to getAgentInitials (same module — it needs the same slug-resolution logic already there):
export type AgentTeamColor =
| "backend"
| "frontend"
| "ux_ui"
| "board"
| "ceo"
| "system";
Derived from the slug prefix (be-* → backend, fe-* → frontend, ux-* → ux_ui, main-pm/product-owner/head-marketing/auditor → board, ceo/CEO → ceo, intake-*/secretary-*/pr-reviewer-* → system), with the same UUID-to-slug resolution getAgentInitials already does via resolveToSlug.
| Bucket | Agents | Token classes (light / dark handled by existing dark: pairs already in the codebase's Tailwind v4 setup) |
|---|---|---|
backend |
be-pm, be-dev-1, be-dev-2, be-qa, be-doc | bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-400 |
frontend |
fe-pm, fe-dev-1, fe-dev-2, fe-qa, fe-doc | bg-violet-500/15 border-violet-500/40 text-violet-700 dark:text-violet-400 |
ux_ui |
ux-pm, ux-dev-1, ux-dev-2, ux-qa, ux-doc | bg-fuchsia-500/15 border-fuchsia-500/40 text-fuchsia-700 dark:text-fuchsia-400 |
board |
main-pm, product-owner, head-marketing, auditor | bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-400 |
ceo |
ceo | bg-primary/15 border-primary/40 text-primary (the app's own accent — the one human gets the app's own color, not a team bucket) |
system |
intake-1, secretary-1, pr-reviewer-1 | bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400 |
Every value here is an existing Tailwind color family already used elsewhere in the codebase for the same semantic weight (amber for attention in release-proposal-card.tsx:181, blue/violet/fuchsia are Tailwind defaults, no new tokens introduced) at the same /15 background + /40 border opacity already established by the pulse-card treatment in a2a-pair-card.tsx:87.
Avatar composition
Extends the existing avatar circle (PairAvatar in a2a-pair-card.tsx:20-31, and the inline avatar in a2a-transcript.tsx:70-74) with the team color as border + bg, keeping the initials as the foreground content — the color becomes a ring around identity, not a replacement for it:
<div
className={cn(
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[getAgentTeamColor(agentId)],
)}
title={getAgentDisplayName(agentId)}
>
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(agentId)}
</span>
</div>
TEAM_COLOR_CLASSES is a Record<AgentTeamColor, string> map of the class strings from the table above, exported alongside getAgentTeamColor so every consumer (transcript rows, pair cards, roster rows, context pane identity cards) applies the identical mapping — one source of truth, no per-component re-derivation.
Accessibility
Color is never the sole differentiator: the title attribute always carries the full display name (already the case in PairAvatar), the initials/code is always visible text inside the circle, and every place an avatar appears the agent's display name renders as adjacent text (already true in the transcript and pair card). A screen reader user gets the name from the text content regardless of the color layer. All six token pairs above meet WCAG AA (4.5:1) for the text-*-700/text-*-400 foreground against a bg-*-500/15 fill over the app's background/card surface — verify against the actual rendered surface at implementation time per the design bar's contrast-audit rule, since a /15 alpha fill's effective contrast depends on what's behind it.
3. Live-stream connection states
States
ConnectionState already has four values (use-websocket.ts:17-21 via lib/websocket/connection.ts); the spec covers all four, since "connecting" (initial handshake) and "reconnecting" (recovering after a drop) share one visual family with a different label:
| State | Dot | Label | Icon (header, inline) | Placement |
|---|---|---|---|---|
connected |
bg-emerald-500, static (no pulse) |
"Live" | none needed — the dot + label is enough, matching the current a2a/page.tsx:224-234 treatment minus the animate-pulse (see motion note below) |
Inline in the pane header, next to the region title |
connecting |
bg-amber-500, animate-pulse |
"Connecting…" | Loader2 with animate-spin, h-3 w-3 (matches connection-status.tsx:35) |
Inline in the pane header |
reconnecting |
bg-amber-500, animate-pulse |
"Reconnecting…" | Loader2 with animate-spin, h-3 w-3 |
Inline in the pane header, plus a thin dismissable strip directly above the stream pane's message list: bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-400 text-xs px-3 py-1.5 reading "Reconnecting — messages may be out of date" |
disconnected |
bg-muted-foreground/40, static |
"Offline" | WifiOff, h-3 w-3, text-muted-foreground |
Inline in the pane header, plus the same strip pattern as reconnecting but bg-destructive/10 border-destructive/30 text-destructive, reading "Disconnected — reconnecting automatically" |
The connected/connecting/reconnecting distinction matters because a user watching a live conversation needs to know why nothing new is arriving: connected-but-quiet means the conversation is genuinely idle; reconnecting/disconnected means the stream itself is the problem, not the conversation. Collapsing all three into one generic "not live" state (as today's binary isConnected ? "Live" : "Offline" does) hides that distinction.
The banner strip is scoped to the stream pane only, not a full-page takeover — this is a live-connection hint, not an application-down state (that's OfflineState, reserved for §5's error case where data can't load at all).
Motion note
The existing animate-pulse dot (a2a/page.tsx:228) is a Tailwind keyframe that only animates opacity, so it already satisfies the "animate transform/opacity only" rule — but it has no prefers-reduced-motion guard today. Add one: wrap the pulsing states in motion-reduce:animate-none, so a reduced-motion user gets a static dot at full opacity instead of the pulse — the color and label alone still convey the state.
4. New-message arrival cue
The cue
When a new message is appended to the stream (a a2a.message frame that resolves to a new row after the existing invalidate-on-frame refetch, a2a/page.tsx:131-140), the new row enters with a transform + opacity only transition — no layout-affecting property, no scroll-listener-driven animation, per the design bar's motion rule:
className={cn(
"flex gap-3 p-3 rounded-lg border bg-card transition-[opacity,transform] duration-200 ease-out",
isNew ? "opacity-0 translate-y-1" : "opacity-100 translate-y-0",
)}
isNew is derived the same render-phase way A2APairCard's isPulsing already is (a2a-pair-card.tsx:49-60): compare the incoming message id against a "last seen" set in render, flip to false on the next animation frame via requestAnimationFrame inside a useEffect — no animation library, matching the codebase's existing idiom for this exact kind of one-shot entrance state.
The starting state (opacity-0 translate-y-1, i.e. 4px down) is applied only for rows that mount already-new (a message arriving while the stream is open); rows present at initial transcript load render straight to opacity-100 translate-y-0 with no transition, so opening a conversation never shows every existing message animating in at once.
Off-screen arrival (scrolled up)
When the user has scrolled up in the stream (not at the bottom) and a new message arrives, do not auto-scroll and do not play the row-entrance transition off-screen. Instead show a small pill anchored to the bottom of the stream pane:
<button
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-primary text-primary-foreground text-xs px-3 py-1 shadow-md transition-[opacity,transform] duration-200 ease-out"
onClick={scrollToBottom}
>
New messages ↓
</button>
— same transform/opacity-only constraint, appearing with the same fade-and-rise-in treatment as the row cue. Clicking it scrolls to bottom (smooth, CSS scroll-behavior: smooth — a browser-native scroll, not a scroll event listener) and dismisses the pill.
prefers-reduced-motion
Both cues drop the translate-y/-translate-x transform and the duration-200 transition under motion-reduce:, leaving only the opacity state change applied instantly (motion-reduce:transition-none motion-reduce:translate-y-0) — the row/pill still visually distinguishes "just arrived" via a brief bg-muted/50 background flash (a non-transform, non-motion cue: a background-color change with its own short transition-colors duration-150, exempt from the transform/opacity restriction because a plain color transition is not motion) that fades to the row's normal bg-card over 150ms, so reduced-motion users still get an arrival signal without any movement.
5. Loading, empty, and error states
All three states are scoped to the stream pane's content area — the pane chrome (header, region title, connection badge) stays mounted and stable across state changes, only the message-list area swaps.
| State | Trigger | Treatment |
|---|---|---|
| Loading | Initial message fetch in flight (loadingMessages in a2a/page.tsx, isLoading prop already on A2ATranscript) |
Existing skeleton rows (A2ATranscript lines 36-48) — 5 rows, each an avatar-shaped Skeleton circle + two text-line skeletons, matching final row shape so there's no layout shift on resolve. No new component needed, this already exists and is correct. |
| Empty | Fetch resolved, zero messages (A2ATranscript lines 51-60) |
Existing centered icon + one-line text (MessagesSquare, opacity-50, text-sm text-muted-foreground). Extend the copy to be context-aware: "No messages in this conversation yet" when a conversation is selected (current copy, unchanged) vs. "Select a conversation to view messages" when nothing is selected yet (the roster-selected-nothing case, not currently distinguished) — same icon, same layout, only the string changes based on whether selectedId/peekedPair is set. |
| Error | The messages fetch itself errors (distinct from the page-level isOffline full-page case at a2a/page.tsx:239-244, which covers the conversations list failing to load) |
A scoped inline state inside the stream pane, not a full-page OfflineState: centered AlertTriangle icon (h-8 w-8 opacity-50 text-destructive), "Couldn't load this conversation" text, and a Button variant="outline" size="sm" "Retry" that calls the existing refetchMessages(). Reuses the same centered-icon-plus-text layout shell as the empty state (same wrapper div, different icon/copy/action) so the three states read as one family, not three unrelated designs. |
The distinction between the page-level OfflineState (backend unreachable entirely, a2a/page.tsx:239-244) and this pane-level error state (this one conversation's message fetch failed, everything else on the page still works) matters: a transient 500 on one conversation's messages should never take over the whole page.
Implementation checklist for the frontend developer
- Add a
contextregion to the/a2apage's grid atxl:, with the persisted collapse toggle described in §1. - Add
getAgentTeamColor+TEAM_COLOR_CLASSEStoagent-utils.ts; apply toPairAvatar, the transcript row avatar, and the new context pane identity cards. - Extend the connection badge in
a2a/page.tsxto render all fourConnectionStatevalues distinctly (§3's table), including the dismissable reconnecting/disconnected strip above the stream. - Add
motion-reduce:animate-noneto the existing pulsing connection dot. - Add the transform/opacity new-row entrance transition to
A2ATranscript's row rendering, plus the "New messages ↓" pill for the scrolled-up case, both withprefers-reduced-motionfallbacks per §4. - Split
A2ATranscript's empty state into conversation-selected vs. nothing-selected copy; add the new scoped error state for a failed messages fetch. - No new Tailwind tokens beyond the six team-color families named in
§2 — every other class already exists in
a2a-pair-card.tsx,a2a-transcript.tsx, orrelease-proposal-card.tsx.