[a360b6e3] Redesign A2A page with conversation-first layout and agent identity (#401)

* [54b94e44] A2A page: filter controls + agent identity consistency (#387) (#392)

* [54b94e44] feat(a2a): add filter bar and unify agent avatars + pulse across views

Adds a status (active/all) + free-text search filter bar above the A2A
switchboard/list content, backed by a shared a2a-filter-utils module so
both A2ASwitchboard's pairs and A2AConversationList's conversations
narrow identically. Extracts A2APairCard's pulse-flash state into a
reusable usePulseFlash hook and exports its PairAvatar so the classic
conversation list now renders the same two-participant avatar and
emerald pulse-flash affordance the switchboard already had.

* [54b94e44] docs(a2a): add comprehensive filtering and avatar documentation

Documented the new A2A filter bar, filter utilities, pulse-flash hook, and
conversation list API changes. Includes examples, testing guidance, and
migration notes for the pulses prop requirement.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [54417f0c] UX/UI: design A2A conversation-first layout and agent identity (#399)

* [f612a5ab] Add conversation-first layout, agent identity, and live-stream affordance spec (#384)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [7ed2ef71] docs(ux_ui): add filter-control design spec for A2A conversations (#383)

Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

* [f563bbc9] Implement conversation-first A2A layout, identity colors, connection states, transcript motion, and empty/error states (#423) (#427)

* [f563bbc9] feat(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states

Implements docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md:
- xl:+ collapsible Context pane (identity cards, linked-task summary, no-task hint), persisted via the existing zustand ui-store
- getAgentTeamColor + TEAM_COLOR_CLASSES in agent-utils.ts, applied to PairAvatar, the transcript row avatar, and the context pane
- A2AConnectionBadge/A2AConnectionBanner rendering all four ConnectionState values distinctly with a motion-reduce-guarded pulsing dot and a dismissable reconnecting/disconnected strip
- A2ATranscript: transform/opacity-only new-row entrance transition, scrolled-up "New messages" pill, split hasSelection/empty/error states with a scoped Retry
- Unit tests for every new pure helper and component

* [f563bbc9] docs(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states

Document the new conversation-first A2A layout features:
- Agent team-color system (getAgentTeamColor, TEAM_COLOR_CLASSES) for six cell buckets
- A2AContextPane component with identity cards, linked task summary, no-task hint
- Connection state rendering (A2AConnectionBadge, A2AConnectionBanner) for all four ConnectionState values
- Transcript entrance motion with transform/opacity-only transitions and prefers-reduced-motion guards
- Split empty/error states (no selection, no messages, fetch error with scoped retry)
- Page-level integration with xl:+ responsive grid layout

Includes component API, usage examples, testing guidance, accessibility notes, and design rationale.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [478f027c] Implement A2A conversations filter control per conversations-filter-control.md (#445) (#448)

* [478f027c] feat(a2a): add multi-dimension Popover filter panel for A2A conversations

Replace the free-text search + active/all toggle with the Popover-triggered
filter control from conversations-filter-control.md: Agent multi-select
checkboxes, a Task id-fragment input with a "No linked task" toggle, Status
toggle buttons, and a date range, plus an active-filter chip row and Clear
all. Filtering applies to both the switchboard (Agent only) and conversation
list (all four dimensions) per the design doc's per-view rules.

* [478f027c] docs(a2a): add comprehensive filter-control guide covering component API, filter dimensions, and per-view rules

Documents A2AFilterBar component and filter utilities with:
- Component API and props
- All 4 filter dimensions (Agent, Task, Status, Date range)
- Per-view rules (Switchboard vs List)
- Usage examples and parent setup
- Filter logic and match predicates
- Testing guide and accessibility notes
- Design notes on client-side filtering limitation
- Links to related components and the design spec

Helps developers understand, use, and maintain the A2A conversations
filter control without needing to read the design doc or component source.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 07:46:29 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Frontend Documenter UX/UI Developer 1 UX/UI Developer 2 Frontend Developer 2 Renn F
parent 58354a364e
commit 6e57066bd6
33 changed files with 3999 additions and 552 deletions
@@ -0,0 +1,350 @@
# 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 `A2ATranscript` plus 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:
1. Both participants' identity cards (avatar + name + team badge — see
§2), each linking to `/agents/{slug}`.
2. The linked task, if any: title (truncated to one line), status `Badge`
(reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`),
and a "View task" link.
3. 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):
```ts
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:
```tsx
<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:
```tsx
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:
```tsx
<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 `context` region to the `/a2a` page's grid at `xl:`, with the
persisted collapse toggle described in §1.
- [ ] Add `getAgentTeamColor` + `TEAM_COLOR_CLASSES` to `agent-utils.ts`;
apply to `PairAvatar`, the transcript row avatar, and the new context
pane identity cards.
- [ ] Extend the connection badge in `a2a/page.tsx` to render all four
`ConnectionState` values distinctly (§3's table), including the
dismissable reconnecting/disconnected strip above the stream.
- [ ] Add `motion-reduce:animate-none` to 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 with `prefers-reduced-motion` fallbacks
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`, or `release-proposal-card.tsx`.
@@ -0,0 +1,230 @@
# Filter control for A2A conversations
Design spec for a filter control on the panel's `/a2a` page
(`panel/src/app/(dashboard)/a2a/page.tsx`), which is the CEO-facing
conversation-first view of agent-to-agent chats. Today the page has no
filtering at all: Panel 1 shows either the org-chart **Switchboard**
(`A2ASwitchboard`) or the classic **Conversation List** (`A2AConversationList`),
capped at the last 100 conversations with no way to narrow them.
This spec covers the filter dimensions, their placement, the active-filter
chip representation, and accessibility. It reuses the Popover + Checkbox +
Badge-chip pattern already shipped in
`panel/src/components/tasks/task-filters.tsx` rather than inventing a new
filter idiom, and stays within primitives already in
`panel/src/components/ui/` (`popover.tsx`, `checkbox.tsx`, `badge.tsx`,
`button.tsx`, `input.tsx`).
## Design-bar dial read
This is a dense admin surface (a live operations console), not a marketing
page, so per the UX/UI team's design bar defaults: **DESIGN_VARIANCE 2**
(the control sits in an already-fixed grid, no asymmetry), **MOTION_INTENSITY
2** (popover open/close and chip enter/exit only, no scroll choreography),
**VISUAL_DENSITY 7** (compact chips and a single icon-button trigger, not an
airy multi-field form bar like the full-width `TaskFilters` on `/tasks`,
because Panel 1 is only 4/12 columns wide at `lg`+).
## 1. Filterable dimensions
Four dimensions, each backed by a field already on the wire today
(`AdminConversationSummary` / `AdminPairSummary` in `panel/src/lib/api/a2a.ts`).
None of these are supported as backend query params yet — `a2aApi.listAdminConversations`
only accepts `limit`. The spec below filters **client-side over the already-fetched
page** (currently capped at `limit=100`); the "Future work" note calls out
the backend seam so a later task can add server-side params without changing
this UI contract.
| # | Dimension | Source field(s) | Value domain | Control widget |
|---|---|---|---|---|
| 1 | **Agent** | `agent_a`, `agent_b` (conversation is a match if either equals a selected agent) | The distinct set of agent slugs present in the currently loaded `conversations`/`pairs` list, deduplicated and sorted, labeled via `getAgentDisplayName()` (`panel/src/lib/agent-utils.ts`) — not a static enum, since the agent roster grows | Checkbox list inside the Filters popover (same pattern as `TaskFilters`' Status/Team checkbox lists) |
| 2 | **Task** | `task_id` (nullable — some conversations aren't task-scoped) | Free-text match against the task's short id (`task_id.slice(0, 8)`, the same truncation `A2AConversationList` already renders) plus an explicit **"No linked task"** toggle for `task_id === null` | A single `Input` (text) for the id fragment, wired with the same 300ms debounce pattern the `/tasks` page uses for `searchQuery`, plus one checkbox for "No linked task" |
| 3 | **Status** | `status` (`"active"` \| `"archived"`, the same two values `A2AConversationList`'s `Badge variant` already branches on) | The 2 known statuses | Two-option toggle group (button pair, `aria-pressed`, same idiom as the page's own Switchboard/List view toggle) — a checkbox list would be overkill for 2 values |
| 4 | **Date/time range** | `last_message_at` (falls back to `created_at` when null, matching the list item's own `formatDistanceToNow` fallback) | Any ISO-8601 instant; user picks calendar dates, compared at day granularity in the viewer's local timezone | Two `Input type="date"` fields labeled "From" / "To" — no date-range-picker component exists in `panel/src/components/ui/`, so this stays within already-installed primitives per the ladder (native `<input type="date">` is a browser-native widget, not a new dependency) |
**Per-view applicability.** The Switchboard is org-chart pair cards
(`AdminPairSummary`), not a conversation feed — most pairs have never
talked (`conversation_id: null`). Only **Agent** applies there (narrows
which pair cards render, same predicate as dimension 1 above); Task/Status/
Date filters have no meaning for a pair with no conversation, so selecting
them while in Switchboard view shows a one-line inline note ("Task, Status,
and Date filters apply to the Conversation List view") rather than silently
hiding pairs. Switching to List view via the existing `LayoutGrid`/`ListIcon`
toggle applies all four dimensions.
**Future work (not this task):** once conversation volume regularly exceeds
the `limit=100` page, promote Task/Status/Date to real backend query params
on `GET /a2a/chat/admin/conversations` (`agent`, `task_id`, `status`,
`from`, `to`) so filtering isn't limited to whatever page happened to load.
Client-side filtering as specified here is correct for the current data
volume and ships without a backend task.
## 2. Placement
The control must not crowd the conversation-first message stream: it lives
entirely inside **Panel 1** (the list/switchboard card), never inside
**Panel 2** (transcript + composer). Concretely, it is added to the existing
Panel-1 header row in `A2APageContent` (`page.tsx` lines ~269-298), which
today holds a `Radio` icon, a label, and the Switchboard/List view-toggle
buttons:
```
Collapsed (no active filters) — Panel 1 header, unchanged height:
┌─────────────────────────────────────────────┐
│ 📻 Switchboard [▤][≡] [⏷ Filters] │ <- new trigger, right-aligned
├─────────────────────────────────────────────┤
│ ...pair cards / conversation list... │
```
The trigger is a single compact `Button variant="outline" size="sm"` reading
`Filters` (funnel icon, `lucide-react`'s `SlidersHorizontal`), matching the
view-toggle buttons' height (`h-7`) so the header row's height never changes
— that's what keeps it from crowding the list below. It shows an active-count
badge inline (`Filters · 2`) instead of a separate counter chip when >=1
filter is set, same abbreviation `TaskFilters` already uses for its
per-dimension triggers (`"${n} statuses"`).
Clicking the trigger opens **one** `Popover` (not four separate popovers
like the full-width `/tasks` page — Panel 1 is too narrow at 4/12 columns
for a row of triggers) containing all four dimension controls stacked
vertically, each in its own labeled section with a small `Clear` link when
that dimension has a selection — directly modeled on each section inside
`TaskFilters`' existing per-dimension `PopoverContent` blocks:
```
Expanded (popover open), anchored bottom-right of the trigger:
┌─────────────────────────┐
│ Agent Clear │
│ ☑ be-dev-1 │
│ ☐ be-qa │
│ ☐ ux-pm │
├─────────────────────────┤
│ Task │
│ [ id fragment... ] │
│ ☐ No linked task │
├─────────────────────────┤
│ Status │
│ [ Active ] [ Archived ] │
├─────────────────────────┤
│ Date range │
│ From [____] To [____] │
├─────────────────────────┤
│ [ Clear all ] │
└─────────────────────────┘
```
Filters apply live as each control changes (no separate "Apply" button) —
consistent with `TaskFilters`, whose `onStatusChange`/`onTeamChange` etc.
fire immediately. The popover's max height is capped (`max-h-[70vh]
overflow-y-auto`, same idea as `TaskFilters`' `max-h-64 overflow-y-auto`
project/product lists) so it never grows taller than the viewport on small
screens.
On mobile (`<lg`, where Panel 1 is the only visible pane per the page's
existing `onDetailLevel` show/hide split), the trigger and popover behave
identically — the popover already clamps to the viewport, so no separate
mobile layout is needed.
## 3. Active-filter chips + clear-all
When one or more filters are active, a **chip row** appears directly below
the Panel-1 header (above the list/switchboard content), pushing the list
down by exactly the chip row's own height — it does not overlay content and
it collapses to zero height (not rendered at all) when no filters are set,
so the empty/default state is pixel-identical to today's layout:
```
┌─────────────────────────────────────────────┐
│ 📻 Switchboard [▤][≡] [⏷ Filters · 3]│
├─────────────────────────────────────────────┤
│ be-dev-1 ✕ Active ✕ From 07/01 ✕ Clear all │ <- chip row, wraps on overflow
├─────────────────────────────────────────────┤
│ ...filtered pair cards / conversation list...│
```
Each chip is a `Badge variant="secondary"` with a trailing `X`
(`lucide-react`) icon button, exactly `TaskFilters`' existing chip markup
(`<Badge variant="secondary" className="gap-1">{label}<X className="h-3 w-3
cursor-pointer hover:text-destructive" onClick={...} /></Badge>`). One chip
per active value:
- **Agent**: one chip per selected agent, labeled with `getAgentDisplayName()`.
- **Task**: one chip for the id-fragment text (`Task: <fragment>`) if set, one
chip labeled `No linked task` if that toggle is on.
- **Status**: one chip per selected status (`Active` / `Archived`).
- **Date range**: up to two chips, `From <date>` and `To <date>`, each
independently removable.
Clicking a chip's `X` removes only that value (unchecking the matching
control inside the popover, same two-way binding `TaskFilters` uses between
its checkbox state and its chip `onClick`). The row wraps (`flex flex-wrap
gap-2`) rather than truncating or scrolling horizontally, so every active
filter stays visible without an extra interaction.
**Clear all** is a `Button variant="ghost" size="sm"` at the end of the chip
row, visible only when >=1 filter is active (same condition that gates the
whole chip row and mirrors `TaskFilters`' own "Clear all" button), and it
resets every one of the four dimensions in a single click.
## 4. Accessibility
### Keyboard operability
| Control | Behavior |
|---|---|
| `Filters` trigger button | Reachable by `Tab` in header-row order (after the view-toggle buttons); `Enter`/`Space` opens the popover; `aria-expanded` reflects open state; `aria-haspopup="dialog"` (Radix `Popover` primitive already provides this) |
| Popover content | Focus moves to the first checkbox on open (Radix default); `Tab`/`Shift+Tab` cycles through all controls in visual top-to-bottom order (Agent checkboxes → Task input → "No linked task" checkbox → Status toggle buttons → From/To date inputs → Clear all); `Escape` closes the popover and returns focus to the trigger (Radix default) |
| Checkboxes (Agent, "No linked task") | `Space` toggles; each wrapped in a `<label>` per `TaskFilters`' existing pattern so the whole row is a hit target, not just the 16px box |
| Status toggle buttons | Real `<button>` elements with `aria-pressed`, not `<div onClick>``Enter`/`Space` toggles, matching the page's existing Switchboard/List `Button` toggle idiom (`aria-pressed={view === "switchboard"}`) |
| Date inputs | Native `<input type="date">`, which ships full keyboard support (arrow keys move segments, typing digits enters them) from the browser — no custom widget to re-implement |
| Chip remove (`X`) | Each chip's `X` is a real `<button aria-label="Remove <chip label> filter">` (icon-only, so `aria-label` is required — the current `TaskFilters` chips use a bare `<X>` with no accessible name, which this spec explicitly fixes rather than copies) |
| Clear all | Real `<button>`, reachable by `Tab` after the last chip |
### WCAG AA contrast
The control introduces zero new colors — every state below reuses the
app's existing shadcn/ui tokens (`panel/src/app/globals.css`), which are
already in production use across the panel, so this control carries no new
contrast risk. Stated minimums (per WCAG 2.1 AA): **4.5:1** for body/label
text, **3:1** for large text (≥18px/14px-bold) and for non-text UI
components (focus rings, icon-only button boundaries).
| Element | Tokens | Notes |
|---|---|---|
| `Filters` trigger label + count | `--foreground` on `--background` (light: `oklch(0.145 0 0)` on `oklch(1 0 0)`) | Near-black on white — the app's default body-text pair, already far above 4.5:1 everywhere else in the panel |
| Checkbox/toggle labels inside popover | `--popover-foreground` on `--popover` | Same near-black-on-white pair as body text |
| Chip text | `--secondary-foreground` on `--secondary` (light: `oklch(0.205 0 0)` on `oklch(0.97 0 0)`) | Same pair `TaskFilters`' own `Badge variant="secondary"` chips already use in production |
| Chip `X` icon (default) | `--muted-foreground` on `--secondary` | `muted-foreground` is the token shadcn ships specifically calibrated to clear 4.5:1 against near-white backgrounds |
| Chip `X` icon (hover) | `--destructive` on `--secondary` | Existing `hover:text-destructive` class already used by `TaskFilters`; destructive red is tuned against both light/dark `--background` per the shared token, not introduced here |
| Status toggle button (selected) | `--primary-foreground` on `--primary` (light: `oklch(0.985 0 0)` on `oklch(0.205 0 0)`) | Near-white on near-black — the app's own primary-button pair |
| Focus-visible ring (all controls) | `--ring` outline, ≥3:1 against `--background` and `--card` | Radix + Tailwind's default `focus-visible:ring` treatment already applied to `Button`/`Checkbox`/`Input` across the panel |
Because every pair above is an existing, already-shipped token combination
(not a new color introduced by this spec), no new contrast audit tooling is
required — QA can spot-check with devtools' contrast inspector against this
table rather than measuring from scratch. Dark mode uses the same token
names with their dark-mode values (`globals.css` `.dark` block), which
preserve the same relative-lightness relationships (e.g. `--secondary` /
`--secondary-foreground` stay a light-text-on-darker-chip pair), so no
dark-mode-specific override is needed.
## 5. Empty and edge states
- **Zero results after filtering**: the list/switchboard content area shows
the same empty-state idiom the components already use for "no data at all"
(`MessagesSquare`/`Radio` icon + one line of `text-muted-foreground`), but
with copy that names the cause: `"No conversations match the current
filters"` plus an inline `Clear all` link — distinct from today's `"No A2A
conversations yet"` (zero data) and `"No allowed A2A pairs configured"`
(zero pairs), so the CEO isn't told there's no data when there's just no
match.
- **Agent list is empty on first load** (conversations/pairs still loading):
the Agent checkbox section shows 2 `Skeleton` rows (same `Skeleton`
component the list/switchboard already use for their own loading state)
instead of an empty list, so the popover doesn't imply there are zero
agents.
- **Filters persist across live updates**: the page already invalidates and
refetches conversations on every `a2a.message` WebSocket frame
(`page.tsx` lines 131-140); filter *state* is local component state, not
derived from the fetch, so an incoming live message does not reset active
filters — it's re-evaluated against the refreshed list.