Files
roboco/docs/components/a2a-filter-control.md
T
f7f411e112 fix(infra): release builds all 17 registry images; pg_dump backup sidecar (#461)
* 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>
2026-07-11 09:20:57 +02:00

14 KiB

A2A Conversations Filter Control

Location: panel/src/components/a2a/ Design spec: docs/ux_ui/design/conversations-filter-control.md Related: A2A page (panel/src/app/(dashboard)/a2a/page.tsx)

Overview

The A2A filter control provides a multi-dimension Popover-triggered filter panel for narrowing down agent-to-agent conversations by Agent, Task, Status, and Date range. It replaces the previous free-text search + status toggle and applies different filter rules to the Switchboard (org-chart pairs) and Conversation List (message feeds) views per the design spec's per-view rules.

Key principle: The same filter state manages both views, but which dimensions apply depends on the active view—Switchboard pairs only narrow by Agent (since they may have no conversation), while the Conversation List applies all four dimensions.

Component API

A2AFilterBar

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

interface A2AFilterBarProps {
  filters: A2AFilters;
  onFiltersChange: (filters: A2AFilters) => void;
  agentOptions: string[];
  view: "switchboard" | "list";
}

Props:

  • filters — The current filter state (see A2AFilters below).
  • onFiltersChange — Callback fired on any filter change; receives the entire updated filter object.
  • agentOptions — Distinct agent slugs to display in the Agent checkbox list, already deduped and sorted. Derive this via distinctA2AAgents(conversations, pairs) in the parent component.
  • view — The active view mode. When "switchboard", an inline note reminds that Task/Status/Date only apply to the List view.

Rendered output:

  • Collapsed (no filters): A compact trigger button labeled Filters with a funnel icon.
  • With active filters: The trigger shows a count badge (Filters · N).
  • Expanded: A Popover displaying all four filter dimensions stacked vertically, plus an inline note if in Switchboard view.
  • Chip row: Below the header, a wrapping row of Badge chips—one per active filter value—with individual remove X buttons and a shared Clear all button. Only rendered when >=1 filter is active.

Filter State

A2AFilters

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

interface A2AFilters {
  agents: string[];              // Selected agent slugs
  taskIdFragment: string;        // Free-text fragment to match against task_id
  noLinkedTask: boolean;         // Match conversations with task_id === null
  statuses: A2AConversationStatus[]; // "active" | "archived"
  dateFrom: string;              // YYYY-MM-DD or ""
  dateTo: string;                // YYYY-MM-DD or ""
}

Empty state: EMPTY_A2A_FILTERS = all fields empty or falsy.

Filter Dimensions

Each dimension narrows the loaded conversations or pairs via a pure matcher function in a2a-filter-utils.ts.

1. Agent (applies to both views)

  • Source: agent_a and agent_b fields on AdminConversationSummary / AdminPairSummary.
  • Match logic: A conversation/pair matches if either participant is in the selected agents array.
  • Widget: Checkbox list in the Popover, with a Clear button when >=1 agent is selected.
  • Empty behavior: If agents.length === 0, all items pass (no agent filter applied).
// Example: agents = ["be-dev-1"]
// Match: conversation with agent_a="be-dev-1" agent_b="be-qa" ✓
// Match: conversation with agent_a="ux-dev-1" agent_b="be-dev-1" ✓
// No match: conversation with agent_a="ux-dev-1" agent_b="ux-qa" ✗

Switchboard only: Pairs are narrowed by Agent alone (design doc §1 "Per-view applicability").

2. Task (List view only)

Combines two controls for maximum flexibility:

  • Task ID fragment input: Free-text match against the full task_id (case-insensitive). Displayed as a single chip labeled Task: <fragment> when set.
  • "No linked task" toggle: Matches conversations with task_id === null. Displayed as a separate chip when active.

Match logic:

IF (fragment is empty AND noLinkedTask is false)
  PASS (no task filter)
ELSE
  PASS if (fragment matches task_id) OR (noLinkedTask is true AND task_id is null)

In other words: if both controls are empty, no filtering; if one or both are set, match conversations that satisfy either condition (OR logic).

// Example 1: taskIdFragment="abcdef", noLinkedTask=false
// Match: task_id="abcdef01-0000-..." ✓
// No match: task_id="ffffffff-0000-..." ✗
// No match: task_id=null ✗

// Example 2: taskIdFragment="", noLinkedTask=true
// No match: task_id="abcdef01-0000-..." ✗
// Match: task_id=null ✓

// Example 3: taskIdFragment="abcdef", noLinkedTask=true
// Match: task_id="abcdef01-0000-..." ✓
// No match: task_id="ffffffff-0000-..." ✗
// Match: task_id=null ✓

Switchboard: This dimension does not apply (pairs may have no conversation to check a task against).

3. Status (List view only)

  • Source: status field on AdminConversationSummary (values: "active" | "archived").
  • Widget: Two toggle buttons (Active / Archived) with aria-pressed.
  • Match logic: A conversation matches if its status is in the selected statuses array.
  • Empty behavior: If statuses.length === 0, all items pass (no status filter applied).
// Example: statuses = ["active"]
// Match: conversation with status="active" ✓
// No match: conversation with status="archived" ✗

Switchboard: This dimension does not apply.

4. Date range (List view only)

  • Source: last_message_at field on AdminConversationSummary, falling back to created_at if null (same fallback the list already uses for display).
  • Widget: Two native <input type="date"> fields (From / To), in the viewer's local timezone.
  • Match logic: Both dates are compared at day granularity. A conversation matches if:
    IF (dateFrom is empty AND dateTo is empty)
      PASS (no date filter)
    ELSE IF (timestamp is null/empty)
      FAIL
    ELSE
      PASS if (timestamp >= dateFrom) AND (timestamp <= dateTo)
    
  • Rendered chips: One chip per set date (From <date> / To <date>), independently removable.
// Example: dateFrom="2026-07-01", dateTo="2026-07-05"
// Match: last_message_at="2026-07-03T10:30:00Z" ✓
// No match: last_message_at="2026-07-10T10:30:00Z" ✗
// No match: last_message_at=null (falls back to created_at if null) [depends on created_at]

Switchboard: This dimension does not apply.

Usage in Components

Parent Setup

In the parent component (e.g., A2APage), wire the filter state and compute the agent options:

import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
import {
  EMPTY_A2A_FILTERS,
  distinctA2AAgents,
  filterConversations,
  filterPairs,
  type A2AFilters,
} from "@/components/a2a/a2a-filter-utils";

function A2APageContent() {
  const [filters, setFilters] = useState<A2AFilters>(EMPTY_A2A_FILTERS);

  // Derive agent options from the loaded data
  const agentOptions = useMemo(
    () => distinctA2AAgents(conversations, pairs),
    [conversations, pairs]
  );

  // Apply filters to both views
  const filteredPairs = useMemo(
    () => filterPairs(pairs, filters),
    [pairs, filters]
  );

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

  return (
    <>
      <A2AFilterBar
        filters={filters}
        onFiltersChange={setFilters}
        agentOptions={agentOptions}
        view={view} // "switchboard" or "list"
      />

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

Filter Functions

filterConversations(conversations, filters): AdminConversationSummary[] Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list.

filterPairs(pairs, filters): AdminPairSummary[] Applies Agent dimension only to narrow switchboard pair cards.

distinctA2AAgents(conversations, pairs): string[] Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a useMemo in the parent whenever pairs/conversations change.

activeA2AFilterCount(filters): number Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date.

Per-View Rules

This is critical: Different dimensions apply depending on which view is active.

Dimension Switchboard (pairs) List (conversations)
Agent ✓ (always applies)
Task ✗ (N/A—pairs may have no conversation)
Status
Date

Switchboard hint: When the view is "switchboard", the Popover displays an inline note: "Task, Status, and Date filters apply to the Conversation List view." This prepares the user if they set those filters before switching to List.

Design Notes

Client-Side Filtering

Important limitation: Filtering currently runs client-side over the already-fetched page of conversations/pairs (capped at limit=100 from the backend). There are no backend query params for these dimensions yet.

Implication: If the loaded data doesn't contain a matching item, the filter won't find it. This is acceptable for the current conversation volume and is explicitly noted in the design spec as "Future work."

Future task: A later PR will add backend query params (agent, task_id, status, from, to) to GET /a2a/chat/admin/conversations so filtering can work server-side without this limitation.

Persistence

Filter state is local to the page component (useState), not persisted to localStorage or the URL. Reloading the page resets filters to empty. This is intentional and consistent with the page's existing behavior (no search persistence today).

Debouncing

The Task ID fragment input does not debounce; it updates the filter state on every keystroke. For a small dataset (100 conversations) this is fine. If performance becomes an issue with larger datasets, add a 300ms debounce in the parent using useCallback + useRef on the onFiltersChange callback.

Testing

Unit Tests

Component tests: panel/src/components/a2a/__tests__/a2a-filter-bar.test.tsx

  • Trigger button rendering (collapsed and with badge count)
  • Popover open/close and focus management
  • Agent checkbox toggling and per-dimension clearing
  • Task input and "No linked task" toggle
  • Status button toggling
  • Date input binding
  • Chip row rendering (one chip per active value)
  • Chip X button removing individual filters
  • Clear all button resetting everything
  • Switchboard view hint message

Utility tests: panel/src/components/a2a/__tests__/a2a-filter-utils.test.ts

  • filterConversations: all four dimensions, combinations
  • filterPairs: Agent dimension only
  • distinctA2AAgents: dedup + sort correctness
  • activeA2AFilterCount: count logic per dimension
  • Edge cases: empty data, null values, case-insensitivity

Integration Tests

panel/src/app/(dashboard)/a2a/__tests__/page.test.tsx includes:

  • Rendering the filter trigger in the page header
  • Switching views and filtering by Agent in Switchboard
  • Switching to List and filtering by Agent, Task ID, and Status

Accessibility

The component follows the design spec's accessibility contract:

  • Trigger button: aria-expanded reflects Popover open state, aria-haspopup="dialog".
  • Checkboxes: Standard HTML <label> + <Checkbox> pair; the whole row is a hit target.
  • Status toggle buttons: Real <button> with aria-pressed, not <div onClick>.
  • Date inputs: Native <input type="date"> with full keyboard support.
  • Chip remove buttons: Icon-only, with aria-label="Remove <chip label> filter".
  • Focus management: Radix Popover handles focus trap and escape-to-close.
  • Contrast: All color pairs are existing shadcn/ui tokens already in production, meeting WCAG AA (4.5:1 minimum).
  • Component: panel/src/components/a2a/a2a-filter-bar.tsx
  • Utilities: panel/src/components/a2a/a2a-filter-utils.ts
  • Tests: a2a-filter-bar.test.tsx, a2a-filter-utils.test.ts
  • Page integration: panel/src/app/(dashboard)/a2a/page.tsx
  • Design spec: docs/ux_ui/design/conversations-filter-control.md
  • Related component (reference pattern): panel/src/components/tasks/task-filters.tsx (the Popover + Checkbox + Badge-chip idiom this one mirrors)

Common Questions

Q: Why doesn't the fragment search the topic field anymore? A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed.

Q: Can I make filters persist across page reloads? A: Not in this version. To add localStorage persistence, wrap setFilters in the parent with useEffect to sync to localStorage and restore on mount. This would be a follow-up task.

Q: What happens to filtered state when new conversations arrive via WebSocket? A: Filters remain active. The filterConversations function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters.

Q: Can filters be set via URL query params? A: Not currently. The state is ephemeral. To support deep-linking (e.g., ?agents=be-dev-1&status=active), add a URL param sync layer in the parent using useSearchParams / useRouter from Next.js. This would be a follow-up task.