Files
roboco/docs/frontend/a2a-filtering.md
T
6e57066bd6 [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>
2026-07-11 07:46:29 +02:00

9.8 KiB

A2A Page Filtering & Agent Identity

Overview

The A2A (Agent-to-Agent) page now features a unified filter bar and consistent agent identity rendering across both the switchboard (org-chart view) and classic conversation list. Both views respond identically to filtering and pulse animations, creating a cohesive experience regardless of the active view.

Filter Bar

The A2AFilterBar component renders above the switchboard/list content and provides two independent filtering controls:

Component

File: panel/src/components/a2a/a2a-filter-bar.tsx

Props:

  • status (A2AStatusFilter): Current status filter, either "active" or "all"
  • onStatusChange (callback): Fires when the Active/All toggle changes
  • search (string): Current search query
  • onSearchChange (callback): Fires as the user types in the search input

Rendering:

  • Search input with placeholder "Search agent or topic..." accepts free-text queries
  • Two toggle buttons: "Active" narrows to conversations with live activity; "All" shows everything
  • Compact styling (7px button height, 3px font size) to avoid crowding the view

Usage:

import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";

<A2AFilterBar
  status={statusFilter}
  onStatusChange={setStatusFilter}
  search={search}
  onSearchChange={setSearch}
/>

Filter Utilities

The a2a-filter-utils.ts module exports pure, testable filtering logic shared by both views.

File: panel/src/components/a2a/a2a-filter-utils.ts

A2AStatusFilter Type

type A2AStatusFilter = "active" | "all";

filterConversations()

Narrows the conversation list to the matching subset based on status and search query.

Parameters:

  • conversations: ReadonlyArray<AdminConversationSummary>
  • status: A2AStatusFilter"active" filters to conversation.status === "active"; "all" passes all
  • search: string — free-text query (case-insensitive)

Behavior:

  • Searches across both agent slugs (raw IDs), their display names (via getAgentDisplayName), and the topic
  • Empty search passes all conversations
  • Status filter is applied first, then search

Example:

const filtered = filterConversations(
  conversations,
  "active",
  "backend qa"
);
// Returns only active conversations where one agent is Backend QA or mentions "backend qa"

filterPairs()

Narrows the switchboard pairs to the matching subset based on status and search query.

Parameters:

  • pairs: ReadonlyArray<AdminPairSummary>
  • status: A2AStatusFilter"active" filters to pairs that have a conversation_id (have A2A'd); "all" passes all
  • search: string — free-text query (case-insensitive)

Behavior:

  • Searches across both agent slugs (raw IDs) and their display names
  • Empty search passes all pairs
  • Status filter is applied first, then search
  • Note: AdminPairSummary has no backend status field, so "active" is interpreted as "has a conversation"

Example:

const filtered = filterPairs(pairs, "active", "auditor");
// Returns only pairs where at least one agent matches "auditor" and the pair has an active conversation

Conversation List Updates

The conversation list now accepts a pulses prop and renders agent avatars.

File: panel/src/components/a2a/a2a-conversation-list.tsx

A2AConversationListProps

New prop:

  • pulses (Record<string, number>): Maps pairKey(agent_a, agent_b) to the epoch timestamp of the latest pulse frame. This is the same pulses map the switchboard uses, keyed identically, so a conversation row flashes in sync with its pair's card on the switchboard.

Example:

<A2AConversationList
  conversations={filteredConversations}
  selectedId={selectedId}
  onSelect={handleSelect}
  isLoading={loadingConversations}
  pulses={pulses}  // New: from page state
/>

ConversationRow Subcomponent

Each conversation renders as a ConversationRow that mirrors the switchboard's pair card styling:

  • Avatars: Two PairAvatar components (initials, colors) matching A2APairCard
  • Pulse animation: Uses usePulseFlash() to determine if the row should flash hot
  • Styling: Emerald background + shadow while pulsing, matches switchboard
  • Selection state: Bordered/highlighted when selected, same as switchboard selection

Breaking Change

The pulses prop is required. If you're calling A2AConversationList from outside the A2A page, you must supply it:

// Old code (will TypeScript error)
<A2AConversationList conversations={data} ... />

// New code
const pulses = { "be-dev-1|be-qa": 1700000000000 };
<A2AConversationList conversations={data} ... pulses={pulses} />

If you don't have a pulses map available, pass an empty object {} — rows won't flash, but selection/interaction will work normally.

Pulse-Flash Hook

File: panel/src/hooks/use-pulse-flash.ts

The usePulseFlash() hook extracts the pulse-flash animation logic from inline state in A2APairCard, making it reusable across the switchboard and conversation list.

usePulseFlash(pulsedAt: number | null): boolean

Returns true for one paint frame after pulsedAt changes to a non-null value, then false. The consumer's CSS transition-duration does the actual fade-out.

How it works:

  1. Seeded to null (not the initial pulsedAt), so a component that mounts already carrying a live pulse still flashes hot
  2. On render, if pulsedAt !== lastSeenPulse, updates lastSeenPulse and sets isPulsing = true if pulsedAt !== null
  3. On the next paint frame (via requestAnimationFrame), flips isPulsing back to false
  4. CSS transition handles the fade — transition-duration: PAIR_PULSE_FADE_MS applied to elements that conditionally render the hot styling

Example:

import { usePulseFlash } from "@/hooks/use-pulse-flash";
import { PAIR_PULSE_FADE_MS } from "@/components/a2a/a2a-switchboard-utils";

export function MyPulsedRow({ pulsedAt }: { pulsedAt: number | null }) {
  const isPulsing = usePulseFlash(pulsedAt);

  return (
    <div
      className={cn("p-2", isPulsing && "bg-emerald-500/15")}
      style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
    >
      {/* content */}
    </div>
  );
}

A2A Page Integration

File: panel/src/app/(dashboard)/a2a/page.tsx

The page composes these pieces:

  1. Filter state: Lifts statusFilter and search to page level
  2. Derived state: Computes filteredPairs and filteredConversations via useMemo on each render
  3. Filter bar: Mounts A2AFilterBar above the view content
  4. Synchronized pulses: Both A2ASwitchboard and A2AConversationList receive the same pulses map, keyed identically, so pulse animations sync across views

Code sketch:

const [statusFilter, setStatusFilter] = useState<A2AStatusFilter>("all");
const [search, setSearch] = useState("");

const filteredPairs = useMemo(
  () => filterPairs(pairs, statusFilter, search),
  [pairs, statusFilter, search]
);

const filteredConversations = useMemo(
  () => filterConversations(conversations, statusFilter, search),
  [conversations, statusFilter, search]
);

// Both views receive the same pulses map
<A2AFilterBar
  status={statusFilter}
  onStatusChange={setStatusFilter}
  search={search}
  onSearchChange={setSearch}
/>

{view === "switchboard" ? (
  <A2ASwitchboard pairs={filteredPairs} pulses={pulses} ... />
) : (
  <A2AConversationList conversations={filteredConversations} pulses={pulses} ... />
)}

Testing

All filtering and pulse behavior is covered by tests:

  • a2a-filter-utils.test.ts: filterConversations() and filterPairs() with status/search combinations
  • a2a-filter-bar.test.tsx: Button pressed state, search input changes, status toggle callbacks
  • a2a-conversation-list.test.tsx: Avatar rendering, pulse flash detection (tests the data-pulsing attribute)
  • page.test.tsx: Filter bar renders, narrows switchboard independently, narrows classic list independently

Run tests with:

cd panel
pnpm test

Design Notes

Status Filter Semantics

The "active" filter has different semantics across views due to data availability:

  • Conversations: active filters to conversation.status === "active" (backend-provided status)
  • Pairs: active filters to pairs with a non-null conversation_id (have A2A'd at least once)

This is intentional and documented in code comments. If the backend adds an explicit AdminPairSummary.status field in the future, update filterPairs() to use it instead of the conversation_id heuristic.

Pulse Animation Timing

The pulse flash is a brief, high-contrast alert (emerald bg + shadow) that fades over PAIR_PULSE_FADE_MS (200ms by default, defined in a2a-switchboard-utils.ts). If you're experiencing chop or seeing the animation cut off, check:

  1. The usePulseFlash() hook is being called (not bypassed)
  2. The consumer element has transition-[background-color,box-shadow] or equivalent CSS
  3. The transitionDuration inline style matches the fade constant

Migration Guide

If you use A2AConversationList in another context:

Before:

<A2AConversationList
  conversations={conversations}
  selectedId={selectedId}
  onSelect={handleSelect}
  isLoading={false}
/>

After:

<A2AConversationList
  conversations={conversations}
  selectedId={selectedId}
  onSelect={handleSelect}
  isLoading={false}
  pulses={{}} // Add this prop (empty object if no pulses available)
/>

If you extract pulse-flash logic into other components:

Import and use the usePulseFlash() hook instead of rolling your own state management:

import { usePulseFlash } from "@/hooks/use-pulse-flash";
import { PAIR_PULSE_FADE_MS } from "@/components/a2a/a2a-switchboard-utils";

const isPulsing = usePulseFlash(pulsedAt);
// Now use isPulsing to conditionally render the hot styling