[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
+316
View File
@@ -0,0 +1,316 @@
# 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`
```typescript
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`
```typescript
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).
```typescript
// 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).**
```typescript
// 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).
```typescript
// 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.
```typescript
// 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:
```typescript
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).
## Related Files
- **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.
+2
View File
@@ -17,6 +17,8 @@ Documentation for the Frontend Cell team.
## Available docs
- [`a2a-filtering.md`](./a2a-filtering.md) — A2A filter bar, conversation list, and pulse-flash hook
- [`a2a-conversation-first-layout.md`](./a2a-conversation-first-layout.md) — Agent identity colors, connection states, context pane, transcript motion, and empty/error states
- [`hooks.md`](./hooks.md) — `usePageRefresh` and `PageRefreshProvider` usage and API reference
## Contributing
@@ -0,0 +1,519 @@
# A2A Conversation-First Layout: Identity Colors, Connection States, Transcript Motion, and Empty States
## Overview
The A2A page now features a conversation-first design with three major enhancements:
1. **Agent identity colors** — every agent is assigned a team-scoped color bucket (Backend, Frontend, UX/UI, Board, CEO, System)
2. **Collapsible context pane** (xl:+ breakpoint) — participant identity cards, linked task summary, and a no-task hint
3. **Live connection states** — four distinct visual states (connected, connecting, reconnecting, disconnected) with dismissable banners
4. **Transcript entrance motion** — new rows fade in with `transform`/`opacity` transitions, guarded by `prefers-reduced-motion`
5. **Split empty/error states** — distinct messaging for "no conversation selected", "selected but empty", and "fetch failed with retry"
This document covers the component API, integration, and usage patterns.
## Agent Team Colors
### Overview
Agent colors are scoped to **team** (cell), not per-agent, because it remains legible at 22-agent scale and never requires new tokens when a cell grows.
**File:** `panel/src/lib/agent-utils.ts`
### `AgentTeamColor` Type
```typescript
export type AgentTeamColor =
| "backend"
| "frontend"
| "ux_ui"
| "board"
| "ceo"
| "system";
```
### `getAgentTeamColor(agentId: string | null | undefined): AgentTeamColor`
Resolves an agent slug (or UUID) to its team color by inspecting the slug prefix. Unknown slugs fall back to `system` — color is a scanning aid, never critical, so unknown agents don't throw.
**Resolution rules:**
- `ceo` or `CEO``"ceo"`
- Slug starts with `be-``"backend"`
- Slug starts with `fe-``"frontend"`
- Slug starts with `ux-``"ux_ui"`
- `main-pm`, `product-owner`, `head-marketing`, `auditor``"board"`
- All others → `"system"`
**Example:**
```tsx
import { getAgentTeamColor } from "@/lib/agent-utils";
const color = getAgentTeamColor("fe-dev-1");
// Returns "frontend"
const fallback = getAgentTeamColor("unknown-slug");
// Returns "system"
```
### `TEAM_COLOR_CLASSES: Record<AgentTeamColor, string>`
Maps each team color to a pre-composed Tailwind class string with `bg-{color}/15`, `border-{color}/40`, and `text-{color}` weights for light and dark modes.
**Exported classes:**
```typescript
{
backend: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-400",
frontend: "bg-violet-500/15 border-violet-500/40 text-violet-700 dark:text-violet-400",
ux_ui: "bg-fuchsia-500/15 border-fuchsia-500/40 text-fuchsia-700 dark:text-fuchsia-400",
board: "bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-400",
ceo: "bg-primary/15 border-primary/40 text-primary",
system: "bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400",
}
```
**No new Tailwind tokens are introduced** — all colors reuse existing families already in the codebase.
**Usage:**
```tsx
import { getAgentTeamColor, TEAM_COLOR_CLASSES, cn } from "@/lib/agent-utils";
export function AgentBadge({ agentId }: { agentId: string }) {
const teamColor = getAgentTeamColor(agentId);
return (
<div className={cn("p-2 rounded border", TEAM_COLOR_CLASSES[teamColor])}>
{getAgentDisplayName(agentId)}
</div>
);
}
```
## Context Pane (xl:+ Collapsible)
The context pane renders participant identity cards, linked task summary, and a no-task hint at the `xl:` breakpoint and above. It is read-only and collapsible via a button in the page header.
**File:** `panel/src/components/a2a/a2a-context-pane.tsx`
### `A2AContextPane` Component
```typescript
interface A2AContextPaneProps {
agentA: string; // Participant slug
agentB: string; // Participant slug
taskId: string | null; // Linked task UUID, or null when no task
}
export function A2AContextPane({ agentA, agentB, taskId }: A2AContextPaneProps)
```
### Rendered Sections
#### Identity Cards
Each participant renders as an `IdentityCard` subcomponent:
- **Avatar:** 9×9px circle with agent initials, colored by `TEAM_COLOR_CLASSES`
- **Name:** Agent display name from `getAgentDisplayName()`
- **Team badge:** Colored outline badge showing team (e.g. "frontend", "board")
- **Link target:** Clicking navigates to `/agents/{slug}`
#### Linked Task Summary
When `taskId` is provided:
- **Title:** Task name (truncated to one line)
- **Status badge:** Colored badge for task status (e.g. "in_progress", "completed")
- **View link:** "View task" hyperlink to `/tasks/{taskId}`
- **Skeleton loading state:** While the task is being fetched
When `taskId` is null or not provided:
- **No-task hint:** "This conversation has no linked task"
### Styling
- Light, minimal 3px padding per section
- Separator border below the "Context" header
- Smooth hover state on identity cards (light background tint)
- Uses existing `border-b` dividers and `space-y-` gap utilities
### Page Integration
The pane is integrated into the A2A page grid layout:
- **Below `xl:`** — hidden (full width for Roster and Stream columns)
- **`xl:` and above** — visible as a third column when open (localStorage-persisted toggle via the page header button)
**Layout grid (xl:+):**
```
Roster (col-span-3) | Stream (col-span-6) | Context (col-span-3)
```
When context pane is closed, grid falls back to:
```
Roster (col-span-4) | Stream (col-span-8)
```
## Connection State Rendering
The connection badge renders in the pane header, and a dismissable banner appears above the message list when reconnecting or disconnected.
**File:** `panel/src/components/a2a/a2a-connection-badge.tsx`
**Utils:** `panel/src/components/a2a/a2a-utils.ts`
### `ConnectionState` Type (from `@/lib/websocket/connection`)
```typescript
type ConnectionState = "connected" | "connecting" | "reconnecting" | "disconnected";
```
### `A2AConnectionBadge` Component
```typescript
export function A2AConnectionBadge({ state }: { state: ConnectionState })
```
**Rendering:**
- **Dot:** 2×2px circle with color based on state (see `connectionDotClasses()`)
- **Label:** Text label from `connectionStateLabel()`
- **Icon:** Spinner for `connecting`/`reconnecting`, WiFi-off for `disconnected`
**All four states render distinctly:**
| State | Dot | Label | Icon | Notes |
|-------|-----|-------|------|-------|
| `connected` | Emerald, static | "Live" | None | No motion (live conversation) |
| `connecting` | Amber, pulsing | "Connecting…" | Spinner | On first connect |
| `reconnecting` | Amber, pulsing | "Reconnecting…" | Spinner | After a drop; messages may be stale |
| `disconnected` | Muted, static | "Offline" | WiFi-off | No auto-recovery |
**Pulsing animation** (`animate-pulse`) is guarded by `motion-reduce:animate-none`, respecting user accessibility settings.
### `A2AConnectionBanner` Component
```typescript
interface A2AConnectionBannerProps {
state: "reconnecting" | "disconnected";
onDismiss: () => void;
}
export function A2AConnectionBanner({
state,
onDismiss,
}: A2AConnectionBannerProps)
```
**Rendering:**
- **Container:** Full-width strip above the message list
- **Background color:** Amber tint for reconnecting; destructive (red) tint for disconnected
- **Message:** "Reconnecting — messages may be out of date" or "Disconnected — reconnecting automatically"
- **Dismiss button:** X icon, right-aligned; fires `onDismiss` callback
The banner is **scoped to the stream pane** — not a page-level `OfflineState` — so multiple A2A tabs can render independent connection states.
### Helper Functions
**`connectionStateLabel(state: ConnectionState): string`**
Returns human-readable label for the connection badge.
**`connectionDotClasses(state: ConnectionState): string`**
Returns Tailwind classes for the connection dot:
- `connected`: `"bg-emerald-500"` (no pulse)
- `connecting` / `reconnecting`: `"bg-amber-500 animate-pulse motion-reduce:animate-none"`
- `disconnected`: `"bg-muted-foreground/40"` (muted static)
## Transcript Entrance Motion & Empty/Error States
The transcript component now supports motion-reduced entrance transitions, a scrolled-up "New messages" pill, and split empty/error states.
**File:** `panel/src/components/a2a/a2a-transcript.tsx`
### `A2ATranscript` Component
```typescript
interface A2ATranscriptProps {
messages: A2AChatMessage[];
isLoading: boolean;
/** False when no conversation/pair is selected at all — distinguishes
* "nothing to show yet" from "selected but empty" (design doc §5).
* Defaults true (existing callers). */
hasSelection?: boolean;
/** True when the messages fetch itself failed — a scoped retry, not the
* page-level OfflineState. */
error?: boolean;
onRetry?: () => void;
}
export function A2ATranscript({
messages,
isLoading,
hasSelection = true,
error = false,
onRetry,
}: A2ATranscriptProps)
```
### Entrance Motion
New transcript rows render with `transform`/`opacity` only (no layout thrashing):
- **Initial state:** `opacity-0 scale-y-95 origin-bottom` (transparent, scaled down from bottom)
- **Animation:** One paint frame after the row renders, transitions to `opacity-100 scale-y-100` with `transition-[opacity,transform] duration-200`
- **prefers-reduced-motion guard:** Falls back to instant `opacity-100` and no scale transform
**Implementation:**
The component tracks message IDs via render-phase state (`seenIds`). Newly arrived IDs are flagged in `newRowIds` for one paint frame, then cleared. A `requestAnimationFrame` batches the class application to avoid layout thrashing.
**No external libraries required** — uses native CSS transitions.
### New Messages Pill
When the user scrolls up and new messages arrive, a dismissable "New messages ↓" pill appears above the transcript, prompting them to scroll to the bottom.
**Behavior:**
- **At bottom:** New rows animate in; pill is hidden
- **Scrolled up:** New rows don't animate in; pill appears instead, allowing the user to catch up at their own pace
- **On dismiss:** Pill vanishes but messages remain in the list
- **On scroll to bottom:** Pill hides and newly arrived rows resume animating in
### Empty & Error States
#### No Conversation Selected
When `hasSelection === false`:
```
┌──────────────────────────────┐
│ │
│ [Messages icon] │
│ "Select a conversation" │
│ │
└──────────────────────────────┘
```
#### Conversation Selected, No Messages
When `hasSelection === true`, `messages.length === 0`, and `error === false`:
```
┌──────────────────────────────┐
│ │
│ [Messages icon] │
│ "No messages yet" │
│ │
└──────────────────────────────┘
```
#### Fetch Error (Scoped Retry)
When `error === true`:
```
┌──────────────────────────────┐
│ │
│ [Alert Triangle icon] │
│ "Failed to load messages" │
│ [Retry button] │
│ │
└──────────────────────────────┘
```
Clicking "Retry" fires the `onRetry()` callback; the consumer is responsible for re-fetching and clearing the `error` flag.
**This is a scoped, transcript-level retry** — not the page-level `OfflineState` that handles network down.
### Message Row Styling
Each message row uses the agent's team color via `TEAM_COLOR_CLASSES`:
- **Avatar:** Colored circle with initials
- **Sender name:** Rendered above the message
- **Timestamp:** Relative time (e.g. "2 minutes ago") from `date-fns`
- **Message body:** Rendered as Markdown via the `<Markdown>` component
## Page Integration
**File:** `panel/src/app/(dashboard)/a2a/page.tsx`
### Grid Layout
The page wires the new components into an `xl:`-responsive grid:
```tsx
<div className="grid grid-cols-4 xl:grid-cols-12 gap-4">
{/* Roster (col-span-3 or col-span-4) */}
{/* Stream (col-span-6 or col-span-8, grows when context is closed) */}
{/* Context pane (col-span-3, hidden below xl:) */}
</div>
```
### Context Pane Toggle
A button in the page header (or Stream pane header) controls the context pane open/closed state, persisted via the `ui-store` zustand slice:
- **Key:** `a2aContextOpen` (boolean, localStorage-backed via zustand)
- **Button:** `PanelRightClose` / `PanelRightOpen` icon from lucide-react
- **Callback:** `setA2aContextOpen(!a2aContextOpen)`
### Connection Badge & Banner
The Stream pane header renders the `A2AConnectionBadge` with the current WebSocket state. When state is `reconnecting` or `disconnected`, the `A2AConnectionBanner` appears above the message list.
**Connection state** is managed via the `useWebSocket()` hook and passed down from page to Stream pane.
### Usage Example
```tsx
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
import { A2AConnectionBadge, A2AConnectionBanner } from "@/components/a2a/a2a-connection-badge";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
export function A2AStreamPane({
agentA,
agentB,
taskId,
messages,
connectionState,
isLoading,
error,
onRetry,
}: {
agentA: string;
agentB: string;
taskId: string | null;
messages: A2AChatMessage[];
connectionState: ConnectionState;
isLoading: boolean;
error?: boolean;
onRetry?: () => void;
}) {
const [dismissedBanner, setDismissedBanner] = useState(false);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b">
<h2 className="font-medium">
{getAgentDisplayName(agentA)} {getAgentDisplayName(agentB)}
</h2>
<A2AConnectionBadge state={connectionState} />
</div>
{connectionState !== "connected" && !dismissedBanner && (
<A2AConnectionBanner
state={connectionState as "reconnecting" | "disconnected"}
onDismiss={() => setDismissedBanner(true)}
/>
)}
<A2ATranscript
messages={messages}
isLoading={isLoading}
hasSelection={true}
error={error}
onRetry={onRetry}
/>
</div>
);
}
```
## Testing
All new components and utilities are covered by unit tests:
- **`agent-utils.test.ts`** — `getAgentTeamColor()`, `TEAM_COLOR_CLASSES` access
- **`a2a-context-pane.test.tsx`** — Identity card rendering, linked task summary, no-task hint
- **`a2a-connection-badge.test.tsx`** — All four connection states, banner dismiss, icon presence
- **`a2a-utils.test.ts`** — `connectionStateLabel()`, `connectionDotClasses()`
- **`a2a-transcript.test.tsx`** — Empty states, entrance motion, new messages pill, error retry
Run tests with:
```bash
cd panel
pnpm test
```
## Accessibility & Responsiveness
### Breakpoints
- **Below `md:`** — Stack vertically, full width
- **`md:` to below `xl:`** — Roster + Stream side-by-side, context pane hidden
- **`xl:` and above** — Roster + Stream + Context (when open)
### Motion
All entrance animations respect `prefers-reduced-motion`:
- Transcript row entry: Falls back to instant `opacity-100`
- Connection dot pulse: Falls back to static `animate-none`
- New messages pill: No motion applied (fade in/out handled by CSS class application)
### Focus & Keyboard
- **Identity card links** — Keyboard-navigable to `/agents/{slug}` and `/tasks/{taskId}`
- **Banner dismiss button** — `aria-label="Dismiss"` for screen readers
- **Connection badge** — No interactive element (informational only)
## Migration & Breaking Changes
### `A2ATranscript` props
The `hasSelection` and `error` props are new but **backward compatible**:
- `hasSelection` defaults to `true` (existing behavior)
- `error` defaults to `false` (no error state)
- `onRetry` is optional (only called if implemented)
Old code continues to work:
```tsx
// Old code (still works)
<A2ATranscript messages={messages} isLoading={false} />
// New code (explicit states)
<A2ATranscript
messages={messages}
isLoading={false}
hasSelection={selectedConversationId !== null}
error={fetchError}
onRetry={refetchMessages}
/>
```
## Design Notes
### Why Team Colors, Not Per-Agent?
Team scoping (Backend, Frontend, etc.) scales to 22 agents without visual noise and doesn't require adding new Tailwind tokens per agent. It maps to the organizational structure and is sufficient for scanning.
### Why `/15` and `/40` Opacity?
The `/15` background and `/40` border provide:
- Visual distinction without overwhelming
- Sufficient contrast for WCAG AA compliance
- Consistency with existing `PairAvatar` pulse treatment
### Why `transform`/`opacity` Only?
Layout-thrashing animations (animating `width`, `height`, `left`, `top`) cause forced reflows on every frame. `transform` and `opacity` changes are GPU-accelerated and don't force layout recalculation, keeping animations smooth.
## Future Enhancements
- Persist context pane width preference (currently only open/closed toggle)
- Add a "Topic" summary in the context pane (when `taskId` is null)
- Extend connection state to show "cached" mode (reading from local storage while reconnecting)
+303
View File
@@ -0,0 +1,303 @@
# 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:**
```tsx
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
```typescript
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:**
```typescript
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:**
```typescript
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:**
```tsx
<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:
```typescript
// 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:**
```tsx
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:**
```tsx
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:
```bash
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:**
```tsx
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={false}
/>
```
**After:**
```tsx
<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:
```tsx
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
```
@@ -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.
+8 -2
View File
@@ -13,6 +13,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
### Upgrade Procedure
1. **Update package.json** with the new version(s):
```json
{
"dependencies": {
@@ -25,13 +26,16 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
```
2. **Regenerate the lockfile without deleting node_modules:**
```bash
cd panel
pnpm install
```
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
3. **Run the quality gate:**
```bash
cd panel
pnpm lint
@@ -39,7 +43,8 @@ This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resol
pnpm test
pnpm build
```
All must pass with no errors before committing.
All must pass with no errors before committing.
4. **Commit the changes:**
```bash
@@ -50,6 +55,7 @@ All must pass with no errors before committing.
### What Changed in 16.1.1 → 16.2.6
This bump included updates to:
- `@babel/parser`, `@babel/types`, `@babel/template`, `@babel/traverse` — minor version improvements
- `@babel/generator`, `@babel/helper-module-imports`, `@babel/helper-validator-identifier` — updated to handle edge cases
- `tinyglobby` — dependency used by ESLint, upgraded from 0.2.15 to 0.2.17
+85 -312
View File
@@ -1,38 +1,19 @@
{
"claim_rules": {
"auditor": [],
"cell_pm": [
"needs_revision",
"pending"
],
"cell_pm": ["needs_revision", "pending"],
"ceo": [],
"developer": [
"needs_revision",
"pending"
],
"documenter": [
"awaiting_documentation",
"pending"
],
"developer": ["needs_revision", "pending"],
"documenter": ["awaiting_documentation", "pending"],
"head_marketing": [],
"main_pm": [
"needs_revision",
"pending"
],
"pr_reviewer": [
"awaiting_pr_review",
"pending"
],
"main_pm": ["needs_revision", "pending"],
"pr_reviewer": ["awaiting_pr_review", "pending"],
"product_owner": [],
"qa": [
"awaiting_qa"
]
"qa": ["awaiting_qa"]
},
"intents": [
{
"allowed_roles": [
"documenter"
],
"allowed_roles": ["documenter"],
"composes": [],
"description": "Claim awaiting_documentation. Returns evidence inline.",
"name": "claim_doc_task",
@@ -40,9 +21,7 @@
"side_effects": []
},
{
"allowed_roles": [
"pr_reviewer"
],
"allowed_roles": ["pr_reviewer"],
"composes": [],
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
"name": "claim_gate_review",
@@ -50,22 +29,15 @@
"side_effects": []
},
{
"allowed_roles": [
"pr_reviewer"
],
"composes": [
"claim",
"start"
],
"allowed_roles": ["pr_reviewer"],
"composes": ["claim", "start"],
"description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.",
"name": "claim_pr_review",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"qa"
],
"allowed_roles": ["qa"],
"composes": [],
"description": "Claim a task in awaiting_qa for review. Returns evidence inline.",
"name": "claim_review",
@@ -73,23 +45,15 @@
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [
"complete"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": ["complete"],
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.",
"name": "complete",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": [],
"description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them \u2014 never push these into a cell). No status change; the verb body owns ownership + criterion validation.",
"name": "declare_coverage",
@@ -97,37 +61,23 @@
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [
"create_subtask"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": ["create_subtask"],
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.",
"name": "delegate",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"head_marketing",
"main_pm",
"product_owner"
],
"composes": [
"escalate_to_ceo"
],
"allowed_roles": ["head_marketing", "main_pm", "product_owner"],
"composes": ["escalate_to_ceo"],
"description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.",
"name": "escalate_to_ceo",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": [],
"description": "Escalate to your role's escalation_target.",
"name": "escalate_up",
@@ -135,12 +85,8 @@
"side_effects": []
},
{
"allowed_roles": [
"qa"
],
"composes": [
"qa_fail"
],
"allowed_roles": ["qa"],
"composes": ["qa_fail"],
"description": "Fail QA with concrete issues. Transitions to needs_revision.",
"name": "fail_review",
"pre_side_effects": [],
@@ -162,27 +108,16 @@
"side_effects": []
},
{
"allowed_roles": [
"developer",
"documenter",
"qa"
],
"composes": [
"block"
],
"allowed_roles": ["developer", "documenter", "qa"],
"composes": ["block"],
"description": "Escalate to PM. Logs a struggle journal entry.",
"name": "i_am_blocked",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"developer"
],
"composes": [
"submit_verification",
"submit_qa"
],
"allowed_roles": ["developer"],
"composes": ["submit_verification", "submit_qa"],
"description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.",
"name": "i_am_done",
"pre_side_effects": [],
@@ -209,111 +144,71 @@
"side_effects": []
},
{
"allowed_roles": [
"documenter"
],
"composes": [
"docs_complete"
],
"allowed_roles": ["documenter"],
"composes": ["docs_complete"],
"description": "Signal docs complete. Transitions to awaiting_pm_review.",
"name": "i_documented",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [
"claim",
"set_plan",
"start"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": ["claim", "set_plan", "start"],
"description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.",
"name": "i_will_plan",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"developer"
],
"composes": [
"claim",
"set_plan",
"start"
],
"allowed_roles": ["developer"],
"composes": ["claim", "set_plan", "start"],
"description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.",
"name": "i_will_work_on",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"developer"
],
"allowed_roles": ["developer"],
"composes": [],
"description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.",
"name": "open_pr",
"pre_side_effects": [],
"side_effects": [
"push_branch",
"create_pr"
]
"side_effects": ["push_branch", "create_pr"]
},
{
"allowed_roles": [
"qa"
],
"composes": [
"qa_pass"
],
"allowed_roles": ["qa"],
"composes": ["qa_pass"],
"description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.",
"name": "pass_review",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"pr_reviewer"
],
"composes": [
"pr_review_done"
],
"allowed_roles": ["pr_reviewer"],
"composes": ["pr_review_done"],
"description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.",
"name": "post_pr_review",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"pr_reviewer"
],
"composes": [
"pr_fail"
],
"allowed_roles": ["pr_reviewer"],
"composes": ["pr_fail"],
"description": "Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail.",
"name": "pr_fail",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"pr_reviewer"
],
"composes": [
"pr_pass"
],
"allowed_roles": ["pr_reviewer"],
"composes": ["pr_pass"],
"description": "Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge.",
"name": "pr_pass",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm"
],
"allowed_roles": ["cell_pm"],
"composes": [],
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.",
"name": "reassign",
@@ -321,66 +216,39 @@
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [
"request_changes"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": ["request_changes"],
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
"name": "request_changes",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"developer",
"documenter",
"main_pm",
"qa"
],
"composes": [
"resume"
],
"allowed_roles": ["cell_pm", "developer", "documenter", "main_pm", "qa"],
"composes": ["resume"],
"description": "Resume a paused task you own. paused -> in_progress.",
"name": "resume",
"pre_side_effects": [],
"side_effects": []
},
{
"allowed_roles": [
"main_pm"
],
"composes": [
"submit_for_review"
],
"allowed_roles": ["main_pm"],
"composes": ["submit_for_review"],
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
"name": "submit_root",
"pre_side_effects": [
"create_root_pr"
],
"pre_side_effects": ["create_root_pr"],
"side_effects": []
},
{
"allowed_roles": [
"cell_pm"
],
"composes": [
"submit_for_review"
],
"allowed_roles": ["cell_pm"],
"composes": ["submit_for_review"],
"description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
"name": "submit_up",
"pre_side_effects": [
"create_pr"
],
"pre_side_effects": ["create_pr"],
"side_effects": []
},
{
"allowed_roles": [
"developer"
],
"allowed_roles": ["developer"],
"composes": [],
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
"name": "sync_branch",
@@ -402,9 +270,7 @@
"side_effects": []
},
{
"allowed_roles": [
"main_pm"
],
"allowed_roles": ["main_pm"],
"composes": [],
"description": "List actionable tasks across all teams (Main PM only).",
"name": "triage_all",
@@ -412,13 +278,8 @@
"side_effects": []
},
{
"allowed_roles": [
"cell_pm",
"main_pm"
],
"composes": [
"unblock"
],
"allowed_roles": ["cell_pm", "main_pm"],
"composes": ["unblock"],
"description": "PM unblocks a blocked task; restores pre-block state.",
"name": "unblock",
"pre_side_effects": [],
@@ -443,175 +304,121 @@
"transitions": [
{
"action": "cancel",
"roles": [
"ceo"
],
"roles": ["ceo"],
"source": "awaiting_ceo_approval",
"target": "cancelled"
},
{
"action": "ceo_approve",
"roles": [
"ceo"
],
"roles": ["ceo"],
"source": "awaiting_ceo_approval",
"target": "completed"
},
{
"action": "ceo_reject",
"roles": [
"ceo"
],
"roles": ["ceo"],
"source": "awaiting_ceo_approval",
"target": "needs_revision"
},
{
"action": "ceo_reject_to_pool",
"roles": [
"ceo"
],
"roles": ["ceo"],
"source": "awaiting_ceo_approval",
"target": "pending"
},
{
"action": "docs_complete",
"roles": [
"documenter"
],
"roles": ["documenter"],
"source": "awaiting_documentation",
"target": "awaiting_pm_review"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "awaiting_documentation",
"target": "cancelled"
},
{
"action": "claim",
"roles": [
"documenter"
],
"roles": ["documenter"],
"source": "awaiting_documentation",
"target": "claimed"
},
{
"action": "escalate_to_ceo",
"roles": [
"head_marketing",
"main_pm",
"product_owner"
],
"roles": ["head_marketing", "main_pm", "product_owner"],
"source": "awaiting_pm_review",
"target": "awaiting_ceo_approval"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "awaiting_pm_review",
"target": "cancelled"
},
{
"action": "complete",
"roles": [
"cell_pm",
"main_pm"
],
"roles": ["cell_pm", "main_pm"],
"source": "awaiting_pm_review",
"target": "completed"
},
{
"action": "request_changes",
"roles": [
"cell_pm",
"main_pm"
],
"roles": ["cell_pm", "main_pm"],
"source": "awaiting_pm_review",
"target": "needs_revision"
},
{
"action": "pr_pass",
"roles": [
"pr_reviewer"
],
"roles": ["pr_reviewer"],
"source": "awaiting_pr_review",
"target": "awaiting_pm_review"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "awaiting_pr_review",
"target": "cancelled"
},
{
"action": "claim",
"roles": [
"pr_reviewer"
],
"roles": ["pr_reviewer"],
"source": "awaiting_pr_review",
"target": "claimed"
},
{
"action": "pr_fail",
"roles": [
"pr_reviewer"
],
"roles": ["pr_reviewer"],
"source": "awaiting_pr_review",
"target": "needs_revision"
},
{
"action": "qa_pass",
"roles": [
"qa"
],
"roles": ["qa"],
"source": "awaiting_qa",
"target": "awaiting_documentation"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "awaiting_qa",
"target": "cancelled"
},
{
"action": "claim",
"roles": [
"qa"
],
"roles": ["qa"],
"source": "awaiting_qa",
"target": "claimed"
},
{
"action": "qa_fail",
"roles": [
"qa"
],
"roles": ["qa"],
"source": "awaiting_qa",
"target": "needs_revision"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "backlog",
"target": "cancelled"
},
@@ -623,21 +430,13 @@
},
{
"action": "escalate_to_ceo",
"roles": [
"head_marketing",
"main_pm",
"product_owner"
],
"roles": ["head_marketing", "main_pm", "product_owner"],
"source": "blocked",
"target": "awaiting_ceo_approval"
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "blocked",
"target": "cancelled"
},
@@ -655,11 +454,7 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "claimed",
"target": "cancelled"
},
@@ -689,19 +484,13 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "in_progress",
"target": "cancelled"
},
{
"action": "pr_review_done",
"roles": [
"pr_reviewer"
],
"roles": ["pr_reviewer"],
"source": "in_progress",
"target": "completed"
},
@@ -719,11 +508,7 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "needs_revision",
"target": "cancelled"
},
@@ -735,11 +520,7 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "paused",
"target": "cancelled"
},
@@ -751,11 +532,7 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "pending",
"target": "cancelled"
},
@@ -773,11 +550,7 @@
},
{
"action": "cancel",
"roles": [
"cell_pm",
"ceo",
"main_pm"
],
"roles": ["cell_pm", "ceo", "main_pm"],
"source": "verifying",
"target": "cancelled"
}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { PageRefreshProvider } from "@/components/providers";
import type {
@@ -47,6 +48,12 @@ vi.mock("@/hooks/use-websocket", () => ({
useA2ALiveStream,
}));
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
// so this suite doesn't need a real QueryClientProvider.
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
@@ -144,6 +151,7 @@ describe("A2APage", () => {
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
@@ -201,6 +209,7 @@ describe("A2APage", () => {
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
@@ -223,6 +232,7 @@ describe("A2APage", () => {
},
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
@@ -273,6 +283,7 @@ describe("A2APage", () => {
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2APage />));
// No invalidation while offline.
@@ -285,6 +296,7 @@ describe("A2APage", () => {
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2APage />));
@@ -302,10 +314,117 @@ describe("A2APage", () => {
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("renders the filter trigger above the switchboard/list content", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("narrows the switchboard's pairs by a selected agent", async () => {
const user = userEvent.setup();
useA2AAdminPairs.mockReturnValue({
data: {
items: [
buildPair(),
buildPair({
agent_a: "auditor",
agent_b: "product-owner",
group_key: "board",
conversation_id: null,
last_message_at: null,
message_count: 0,
}),
],
total: 2,
},
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
});
it("narrows the classic list's conversations by a selected agent", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation(),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
topic: "Design review",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
});
it("narrows the classic list's conversations by task id fragment", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
task_id: "11111111-2222-3333-4444-555555555555",
}),
buildConversation({
id: "conv-2",
topic: "Design review",
task_id: "99999999-8888-7777-6666-555555555555",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
});
});
+166 -54
View File
@@ -22,13 +22,27 @@ import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
import {
A2AConnectionBadge,
A2AConnectionBanner,
} from "@/components/a2a/a2a-connection-badge";
import { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-utils";
import {
distinctA2AAgents,
filterConversations,
filterPairs,
EMPTY_A2A_FILTERS,
type A2AFilters,
} from "@/components/a2a/a2a-filter-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
@@ -37,6 +51,8 @@ import {
LayoutGrid,
List as ListIcon,
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Radio,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
@@ -48,23 +64,6 @@ interface PeekedPair {
agent_b: string;
}
function EmptyPanel({
icon: Icon,
message,
}: {
icon: typeof MessagesSquare;
message: string;
}) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
function A2APageContent() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -80,6 +79,25 @@ function A2APageContent() {
// shown as an explicit "no A2A yet" state in the drill-in panel.
const [peekedPair, setPeekedPair] = useState<PeekedPair | null>(null);
// Filter panel: Agent, Task (id fragment + no-linked-task), Status, Date
// range — narrows both the switchboard's pairs (Agent only) and the
// list's conversations (all four), per the design doc's per-view rules.
const [filters, setFilters] = useState<A2AFilters>(EMPTY_A2A_FILTERS);
// xl:+ context pane collapse, persisted via the shared UI store — same
// idiom as sidebar/theme preferences (design doc §1).
const contextOpen = useUIStore((s) => s.a2aContextOpen);
const toggleContext = useUIStore((s) => s.toggleA2AContext);
// Reconnecting/disconnected banner strip, dismissable per occurrence — it
// reappears the next time the connection drops (design doc §3). Render-phase
// reset (compared against the previous connectionState, same idiom as
// A2APairCard's usePulseFlash) rather than an effect.
const [bannerDismissed, setBannerDismissed] = useState(false);
const [lastConnectionState, setLastConnectionState] = useState<string | null>(
null,
);
const {
data: conversationData,
isLoading: loadingConversations,
@@ -94,6 +112,7 @@ function A2APageContent() {
const {
data: messagesData,
isLoading: loadingMessages,
error: messagesError,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
@@ -127,7 +146,12 @@ function A2APageContent() {
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
// react-query refetches the affected queries.
const { lastMessage, a2aMessages, isConnected } = useA2ALiveStream();
const {
lastMessage,
a2aMessages,
isConnected,
state: connectionState,
} = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
@@ -139,6 +163,18 @@ function A2APageContent() {
}
}, [lastMessage, queryClient, selectedId]);
// Re-arm the dismissable banner the next time the connection actually
// drops, rather than leaving it dismissed forever after the first hiccup.
if (connectionState !== lastConnectionState) {
setLastConnectionState(connectionState);
if (
connectionState !== "reconnecting" &&
connectionState !== "disconnected"
) {
setBannerDismissed(false);
}
}
// On /ws/system reconnect (false → true) the A2A list is stale — events
// missed during the disconnect. Invalidate the a2a query family so
// react-query refetches. Initial mount with isConnected=true does NOT
@@ -158,6 +194,10 @@ function A2APageContent() {
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const filteredPairs = useMemo(
() => filterPairs(pairs, filters),
[pairs, filters],
);
const handleSelect = useCallback(
(id: string) => {
@@ -186,7 +226,18 @@ function A2APageContent() {
[handleSelect, router, searchParams],
);
const conversations = conversationData?.items ?? [];
const conversations = useMemo(
() => conversationData?.items ?? [],
[conversationData],
);
const filteredConversations = useMemo(
() => filterConversations(conversations, filters),
[conversations, filters],
);
const agentOptions = useMemo(
() => distinctA2AAgents(conversations, pairs),
[conversations, pairs],
);
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const messages = messagesData?.items ?? [];
const lastSender = lastSenderOf(messages);
@@ -220,19 +271,24 @@ function A2APageContent() {
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 rounded-full",
isConnected
? "bg-emerald-500 animate-pulse"
: "bg-muted-foreground/40",
<A2AConnectionBadge state={connectionState} />
{/* Context pane never appears below xl — its toggle is hidden
there too, matching the switchboard/list toggle's placement
idiom (design doc §1). */}
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
title={contextOpen ? "Hide context panel" : "Show context panel"}
>
{contextOpen ? (
<PanelRightClose className="h-3.5 w-3.5" />
) : (
<PanelRightOpen className="h-3.5 w-3.5" />
)}
/>
<span className="text-xs text-muted-foreground">
{isConnected ? "Live" : "Offline"}
</span>
</div>
</Button>
</div>
</div>
@@ -262,6 +318,7 @@ function A2APageContent() {
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
contextOpen && "xl:col-span-3",
onDetailLevel ? "hidden" : "flex",
)}
>
@@ -296,10 +353,16 @@ function A2APageContent() {
</Button>
</div>
</div>
<A2AFilterBar
filters={filters}
onFiltersChange={setFilters}
agentOptions={agentOptions}
view={view}
/>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={pairs}
pairs={filteredPairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
@@ -307,10 +370,11 @@ function A2APageContent() {
/>
) : (
<A2AConversationList
conversations={conversations}
conversations={filteredConversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
pulses={pulses}
/>
)}
</div>
@@ -321,12 +385,25 @@ function A2APageContent() {
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
contextOpen && "xl:col-span-6",
onDetailLevel ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
{selected ? (
{peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<>
{selected && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
@@ -336,7 +413,9 @@ function A2APageContent() {
</span>
<Badge
variant={
selected.status === "active" ? "default" : "secondary"
selected.status === "active"
? "default"
: "secondary"
}
className="text-xs"
>
@@ -344,13 +423,33 @@ function A2APageContent() {
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))} ago
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
ago
</span>
</div>
)}
{/* Scoped to the stream pane, not a full-page takeover —
a live-connection hint, distinct from OfflineState. */}
{(connectionState === "reconnecting" ||
connectionState === "disconnected") &&
!bannerDismissed && (
<div className="-mx-3 mb-3">
<A2AConnectionBanner
state={connectionState}
onDismiss={() => setBannerDismissed(true)}
/>
</div>
)}
{/* All three loading/empty/error states live inside
A2ATranscript now — the pane chrome above stays mounted
and stable while only this area swaps (design doc §5). */}
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
hasSelection={!!selected}
error={!!messagesError}
onRetry={() => void refetchMessages()}
/>
</div>
{/* Reply composer. The backend's reply route rejects with
@@ -360,6 +459,7 @@ function A2APageContent() {
instead of letting the send bounce. Status does NOT gate
the composer: the CEO's reply lands in their own direct
thread with the participant, not in this conversation. */}
{selected && (
<div className="shrink-0 border-t -mx-3">
{selected.task_id ? (
<A2AReplyComposer
@@ -372,31 +472,43 @@ function A2APageContent() {
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped to
a task).
can&apos;t be sent (A2A messages are always scoped
to a task).
</div>
)}
</div>
)}
</>
) : peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<EmptyPanel
icon={MessagesSquare}
message="Select a conversation to watch it live"
/>
)}
</CardContent>
</Card>
{/* Panel 3: Context (xl:+ only, dismissible) — participant
identity cards + linked-task summary, read-only (design doc
§1). */}
{contextOpen && (
<Card className="hidden xl:col-span-3 xl:flex xl:flex-col overflow-hidden">
<CardContent className="p-0 flex-1 overflow-y-auto">
{selected ? (
<A2AContextPane
agentA={selected.agent_a}
agentB={selected.agent_b}
taskId={selected.task_id}
/>
) : peekedPair ? (
<A2AContextPane
agentA={peekedPair.agent_a}
agentB={peekedPair.agent_b}
taskId={null}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground p-4 text-center text-sm">
Select a conversation to see participant details
</div>
)}
</CardContent>
</Card>
)}
</div>
</>
)}
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ConnectionState } from "@/lib/websocket/connection";
import {
A2AConnectionBadge,
A2AConnectionBanner,
} from "../a2a-connection-badge";
describe("A2AConnectionBadge", () => {
it.each([
["connected", "Live"],
["connecting", "Connecting…"],
["reconnecting", "Reconnecting…"],
["disconnected", "Offline"],
] satisfies [ConnectionState, string][])(
"renders a distinct label for %s",
(state, label) => {
render(<A2AConnectionBadge state={state} />);
expect(screen.getByText(label)).toBeInTheDocument();
},
);
});
describe("A2AConnectionBanner", () => {
it("reads 'Reconnecting' for the reconnecting state", () => {
render(<A2AConnectionBanner state="reconnecting" onDismiss={vi.fn()} />);
expect(
screen.getByText(/Reconnecting — messages may be out of date/),
).toBeInTheDocument();
});
it("reads 'Disconnected' for the disconnected state", () => {
render(<A2AConnectionBanner state="disconnected" onDismiss={vi.fn()} />);
expect(
screen.getByText(/Disconnected — reconnecting automatically/),
).toBeInTheDocument();
});
it("dismiss button fires onDismiss", () => {
const onDismiss = vi.fn();
render(<A2AConnectionBanner state="disconnected" onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "task-1",
title: "Ship the context pane",
description: "",
acceptance_criteria: [],
status: TaskStatus.IN_PROGRESS,
priority: 1,
project_id: "proj-1",
task_type: TaskType.CODE,
team: Team.FRONTEND,
assigned_to: null,
parent_task_id: null,
branch_name: null,
pr_number: null,
pr_url: null,
created_at: "2026-07-02T10:00:00Z",
updated_at: "2026-07-02T10:00:00Z",
...overrides,
} as Task;
}
let mockTask: Task | undefined;
let mockIsLoading = false;
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: mockTask, isLoading: mockIsLoading }),
}));
import { A2AContextPane } from "../a2a-context-pane";
describe("A2AContextPane", () => {
it("renders both participants' identity cards linking to /agents/{slug}", () => {
mockTask = undefined;
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend QA")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Backend Dev 1/ })).toHaveAttribute(
"href",
"/agents/be-dev-1",
);
});
it("shows the no-task hint when the conversation has no linked task", () => {
mockTask = undefined;
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
expect(
screen.getByText("This conversation has no linked task"),
).toBeInTheDocument();
});
it("shows the linked task's title, status, and a View task link", () => {
mockTask = buildTask({ title: "Ship the context pane" });
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId="task-1" />);
expect(screen.getByText("Ship the context pane")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /View task/ })).toHaveAttribute(
"href",
"/tasks/task-1",
);
});
});
@@ -30,12 +30,16 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{}}
/>,
);
// Participants via getAgentDisplayName ("{a} <-> {b}").
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
// Both participants get an avatar, matching A2APairCard's PairAvatar.
expect(screen.getByTitle("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByTitle("Backend QA")).toBeInTheDocument();
// Topic, preview, message count, relative timestamp.
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(
@@ -61,6 +65,7 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={onSelect}
isLoading={false}
pulses={{}}
/>,
);
fireEvent.click(screen.getByRole("button"));
@@ -75,6 +80,7 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={onSelect}
isLoading={false}
pulses={{}}
/>,
);
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
@@ -88,8 +94,25 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{}}
/>,
);
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
});
it("flashes a row hot when its pair's pulse key matches (same key as the switchboard)", () => {
render(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{ "be-dev-1|be-qa": 1700000000000 }}
/>,
);
expect(screen.getByTestId("conversation-row")).toHaveAttribute(
"data-pulsing",
"true",
);
});
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { A2AFilterBar } from "../a2a-filter-bar";
import { EMPTY_A2A_FILTERS, type A2AFilters } from "../a2a-filter-utils";
function renderBar(
overrides: Partial<Omit<Parameters<typeof A2AFilterBar>[0], "filters">> & {
filters?: A2AFilters;
} = {},
) {
const onFiltersChange = vi.fn();
const { filters, ...rest } = overrides;
render(
<A2AFilterBar
filters={filters ?? EMPTY_A2A_FILTERS}
onFiltersChange={onFiltersChange}
agentOptions={["be-dev-1", "be-qa"]}
view="list"
{...rest}
/>,
);
return { onFiltersChange };
}
describe("A2AFilterBar", () => {
it("renders a collapsed trigger with no active-count badge by default", () => {
renderBar();
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("shows the active-count badge on the trigger when filters are set", () => {
renderBar({ filters: { ...EMPTY_A2A_FILTERS, agents: ["be-dev-1"] } });
expect(
screen.getByRole("button", { name: "Filters · 1" }),
).toBeInTheDocument();
});
it("opens the popover and renders the Agent checkbox list", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend QA")).toBeInTheDocument();
});
it("fires onFiltersChange when an Agent checkbox is toggled", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Backend Dev 1" }));
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
});
});
it("renders the Task id-fragment input and the No linked task toggle", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByLabelText("Task id fragment")).toBeInTheDocument();
expect(
screen.getByRole("checkbox", { name: "No linked task" }),
).toBeInTheDocument();
});
it("fires onFiltersChange when the task id fragment is typed", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "a");
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
taskIdFragment: "a",
});
});
it("renders Status toggle buttons and two date-range inputs", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByRole("button", { name: "Active" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Archived" }),
).toBeInTheDocument();
expect(screen.getByLabelText("From date")).toBeInTheDocument();
expect(screen.getByLabelText("To date")).toBeInTheDocument();
});
it("fires onFiltersChange when a status button is toggled", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("button", { name: "Active" }));
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
statuses: ["active"],
});
});
it("shows an inline note that Task/Status/Date apply to the List view only when view=switchboard", async () => {
const user = userEvent.setup();
renderBar({ view: "switchboard" });
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(
screen.getByText(
"Task, Status, and Date filters apply to the Conversation List view.",
),
).toBeInTheDocument();
});
it("renders one chip per active filter value plus a Clear all action", () => {
renderBar({
filters: {
agents: ["be-dev-1"],
taskIdFragment: "",
noLinkedTask: false,
statuses: ["active"],
dateFrom: "2026-07-01",
dateTo: "",
},
});
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("From 2026-07-01")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Clear all" }),
).toBeInTheDocument();
});
it("removes only the matching filter when a chip's remove button is clicked", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar({
filters: {
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
statuses: ["active"],
},
});
await user.click(
screen.getByRole("button", { name: "Remove Active filter" }),
);
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
statuses: [],
});
});
it("resets every dimension when Clear all is clicked", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar({
filters: {
agents: ["be-dev-1"],
taskIdFragment: "abc",
noLinkedTask: true,
statuses: ["active"],
dateFrom: "2026-07-01",
dateTo: "2026-07-05",
},
});
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(onFiltersChange).toHaveBeenCalledWith(EMPTY_A2A_FILTERS);
});
it("renders no chip row or Clear all when no filters are active", () => {
renderBar();
expect(
screen.queryByRole("button", { name: "Clear all" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
import {
EMPTY_A2A_FILTERS,
activeA2AFilterCount,
distinctA2AAgents,
filterConversations,
filterPairs,
type A2AFilters,
} from "../a2a-filter-utils";
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: null,
status: "active",
message_count: 3,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: null,
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: null,
last_message_at: null,
message_count: 0,
...overrides,
};
}
function filters(overrides: Partial<A2AFilters> = {}): A2AFilters {
return { ...EMPTY_A2A_FILTERS, ...overrides };
}
describe("filterConversations", () => {
it("passes everything through with no active filters", () => {
const conversations = [
buildConversation({ status: "active" }),
buildConversation({ id: "conv-2", status: "archived" }),
];
expect(filterConversations(conversations, filters())).toHaveLength(2);
});
it("narrows by selected agents (either participant matches)", () => {
const conversations = [
buildConversation({
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
}),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
}),
];
const result = filterConversations(
conversations,
filters({ agents: ["be-qa"] }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("narrows by task id fragment (case-insensitive)", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: "abcdef01-0000" }),
buildConversation({ id: "conv-2", task_id: "ffffffff-0000" }),
];
const result = filterConversations(
conversations,
filters({ taskIdFragment: "ABCDEF" }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("narrows to task_id === null when noLinkedTask is set", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: null }),
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
];
const result = filterConversations(
conversations,
filters({ noLinkedTask: true }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("ORs the task fragment and no-linked-task toggle when both are set", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: null }),
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
buildConversation({ id: "conv-3", task_id: "zzzzzzzz-0000" }),
];
const result = filterConversations(
conversations,
filters({ taskIdFragment: "abcdef", noLinkedTask: true }),
);
expect(result.map((c) => c.id).sort()).toEqual(["conv-1", "conv-2"]);
});
it("narrows by selected statuses", () => {
const conversations = [
buildConversation({ id: "conv-1", status: "active" }),
buildConversation({ id: "conv-2", status: "archived" }),
];
const result = filterConversations(
conversations,
filters({ statuses: ["archived"] }),
);
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
});
it("narrows by date range on last_message_at at day granularity", () => {
const conversations = [
buildConversation({
id: "conv-1",
last_message_at: "2026-07-02T12:00:00Z",
}),
buildConversation({
id: "conv-2",
last_message_at: "2026-07-05T12:00:00Z",
}),
];
const result = filterConversations(
conversations,
filters({ dateFrom: "2026-07-03", dateTo: "2026-07-06" }),
);
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
});
it("falls back to created_at for the date range when last_message_at is null", () => {
const conversations = [
buildConversation({
id: "conv-1",
last_message_at: null,
created_at: "2026-07-02T12:00:00Z",
}),
];
expect(
filterConversations(
conversations,
filters({ dateFrom: "2026-07-02", dateTo: "2026-07-02" }),
),
).toHaveLength(1);
expect(
filterConversations(conversations, filters({ dateFrom: "2026-07-03" })),
).toHaveLength(0);
});
});
describe("filterPairs", () => {
it("passes everything through with no active filters", () => {
const pairs = [
buildPair({ conversation_id: "conv-1" }),
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
];
expect(filterPairs(pairs, filters())).toHaveLength(2);
});
it("narrows by selected agents only — Task/Status/Date never apply", () => {
const pairs = [
buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" }),
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
];
const result = filterPairs(
pairs,
filters({
agents: ["be-dev-1"],
statuses: ["archived"],
dateFrom: "2099-01-01",
}),
);
expect(result).toHaveLength(1);
expect(result[0].agent_a).toBe("be-dev-1");
});
});
describe("distinctA2AAgents", () => {
it("dedupes and sorts agent slugs across conversations and pairs", () => {
const conversations = [
buildConversation({ agent_a: "fe-qa", agent_b: "be-qa" }),
];
const pairs = [buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" })];
expect(distinctA2AAgents(conversations, pairs)).toEqual([
"be-dev-1",
"be-qa",
"fe-qa",
]);
});
});
describe("activeA2AFilterCount", () => {
it("counts zero for the empty filter state", () => {
expect(activeA2AFilterCount(EMPTY_A2A_FILTERS)).toBe(0);
});
it("counts one entry per active chip", () => {
expect(
activeA2AFilterCount(
filters({
agents: ["be-dev-1", "be-qa"],
statuses: ["active"],
dateFrom: "2026-07-01",
}),
),
).toBe(4);
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard } from "../a2a-pair-card";
import { A2APairCard, PairAvatar } from "../a2a-pair-card";
function buildPair(
overrides: Partial<AdminPairSummary> = {},
@@ -51,6 +51,13 @@ describe("A2APairCard", () => {
expect(screen.queryByText("5")).not.toBeInTheDocument();
});
it("colors each avatar by team, not a per-agent hue", () => {
render(<PairAvatar slug="fe-dev-1" />);
expect(screen.getByTitle("Frontend Dev 1")).toHaveClass(
"border-violet-500/40",
);
});
it("marks the card as selected via aria-pressed", () => {
render(
<A2APairCard
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { A2AChatMessage } from "@/lib/api/a2a";
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
@@ -75,4 +75,100 @@ describe("A2ATranscript", () => {
screen.getByText(/No messages in this conversation yet/),
).toBeInTheDocument();
});
it("shows a distinct nothing-selected hint when hasSelection is false", () => {
render(
<A2ATranscript messages={[]} isLoading={false} hasSelection={false} />,
);
expect(
screen.getByText("Select a conversation to view messages"),
).toBeInTheDocument();
expect(
screen.queryByText(/No messages in this conversation yet/),
).not.toBeInTheDocument();
});
it("shows the scoped error state with a working Retry button", () => {
const onRetry = vi.fn();
render(
<A2ATranscript messages={[]} isLoading={false} error onRetry={onRetry} />,
);
expect(
screen.getByText("Couldn't load this conversation"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("error state takes precedence over the empty/nothing-selected states", () => {
render(<A2ATranscript messages={[]} isLoading={false} error />);
expect(
screen.queryByText(/No messages in this conversation yet/),
).not.toBeInTheDocument();
});
});
describe("A2ATranscript new-row entrance (frame -> row fades in, then settles)", () => {
// Deterministic rAF, same idiom as A2APairCard's pulse test.
let rafCallback: FrameRequestCallback | null = null;
beforeEach(() => {
rafCallback = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
rafCallback = cb;
return 1;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders initially-loaded messages settled (never marked new)", () => {
render(
<A2ATranscript
messages={[buildMessage({ id: "m1" })]}
isLoading={false}
/>,
);
const rows = screen.getAllByTestId("transcript-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveAttribute("data-new", "false");
});
it("marks a message arriving after initial load as new, then settles it next frame", () => {
const { rerender } = render(
<A2ATranscript
messages={[buildMessage({ id: "m1" })]}
isLoading={false}
/>,
);
rerender(
<A2ATranscript
messages={[
buildMessage({ id: "m1" }),
buildMessage({
id: "m2",
content: "second",
created_at: "2026-07-02T10:05:00Z",
}),
]}
isLoading={false}
/>,
);
const rows = screen.getAllByTestId("transcript-row");
const newRow = rows.find((r) => r.textContent?.includes("second"));
expect(newRow).toHaveAttribute("data-new", "true");
act(() => {
rafCallback?.(0);
});
const settledRows = screen.getAllByTestId("transcript-row");
for (const row of settledRows) {
expect(row).toHaveAttribute("data-new", "false");
}
});
});
@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import {
connectionDotClasses,
connectionStateLabel,
lastSenderOf,
pickDefaultRecipient,
recipientOptions,
} from "../a2a-utils";
import type { ConnectionState } from "@/lib/websocket/connection";
describe("lastSenderOf", () => {
it("returns null for an empty transcript", () => {
@@ -52,3 +55,37 @@ describe("recipientOptions", () => {
expect(recipientOptions("be-dev-1", "ceo")).toEqual(["be-dev-1"]);
});
});
describe("connectionStateLabel (design doc §3 — all four states distinct)", () => {
it.each([
["connected", "Live"],
["connecting", "Connecting…"],
["reconnecting", "Reconnecting…"],
["disconnected", "Offline"],
] satisfies [ConnectionState, string][])(
"labels %s as %s",
(state, label) => {
expect(connectionStateLabel(state)).toBe(label);
},
);
});
describe("connectionDotClasses", () => {
it("connected is static — no pulse", () => {
expect(connectionDotClasses("connected")).not.toContain("animate-pulse");
});
it("connecting and reconnecting pulse with a motion-reduce guard", () => {
for (const state of ["connecting", "reconnecting"] as ConnectionState[]) {
const classes = connectionDotClasses(state);
expect(classes).toContain("animate-pulse");
expect(classes).toContain("motion-reduce:animate-none");
}
});
it("disconnected is static and muted", () => {
const classes = connectionDotClasses("disconnected");
expect(classes).not.toContain("animate-pulse");
expect(classes).toContain("muted-foreground");
});
});
@@ -0,0 +1,67 @@
"use client";
import { Loader2, WifiOff, X } from "lucide-react";
import type { ConnectionState } from "@/lib/websocket/connection";
import { cn } from "@/lib/utils";
import { connectionDotClasses, connectionStateLabel } from "./a2a-utils";
/** Pane-header connection indicator: dot + label, plus a spinner/offline icon
* for the connecting/reconnecting/disconnected states (design doc §3). All
* four `ConnectionState` values render distinctly — a live-but-quiet
* conversation must read differently from a stream that is the problem. */
export function A2AConnectionBadge({ state }: { state: ConnectionState }) {
return (
<div className="flex items-center gap-1.5">
<span
className={cn("h-2 w-2 rounded-full", connectionDotClasses(state))}
/>
<span className="text-xs text-muted-foreground">
{connectionStateLabel(state)}
</span>
{(state === "connecting" || state === "reconnecting") && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{state === "disconnected" && (
<WifiOff className="h-3 w-3 text-muted-foreground" />
)}
</div>
);
}
interface A2AConnectionBannerProps {
state: "reconnecting" | "disconnected";
onDismiss: () => void;
}
/** Dismissable strip above the stream pane's message list — a scoped
* live-connection hint, not the full-page `OfflineState` (design doc §3). */
export function A2AConnectionBanner({
state,
onDismiss,
}: A2AConnectionBannerProps) {
const isDisconnected = state === "disconnected";
return (
<div
className={cn(
"flex items-center justify-between gap-2 border-b text-xs px-3 py-1.5",
isDisconnected
? "bg-destructive/10 border-destructive/30 text-destructive"
: "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400",
)}
>
<span>
{isDisconnected
? "Disconnected — reconnecting automatically"
: "Reconnecting — messages may be out of date"}
</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import { useTask } from "@/hooks/use-tasks";
import { cn } from "@/lib/utils";
import { ListTodo, Users } from "lucide-react";
/** One participant's identity card — avatar, name, team badge — linking to
* the agent's own detail page (design doc §1/§2). Read-only, same team-color
* mapping every other identity affordance uses. */
function IdentityCard({ slug }: { slug: string }) {
const teamColor = getAgentTeamColor(slug);
return (
<Link
href={`/agents/${slug}`}
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<div
className={cn(
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[teamColor],
)}
title={getAgentDisplayName(slug)}
>
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(slug)}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{getAgentDisplayName(slug)}
</div>
<Badge
variant="outline"
className={cn("text-[10px] mt-0.5", TEAM_COLOR_CLASSES[teamColor])}
>
{teamColor.replace("_", "/")}
</Badge>
</div>
</Link>
);
}
interface A2AContextPaneProps {
agentA: string;
agentB: string;
/** null when this conversation (or peeked pair) has no linked task. */
taskId: string | null;
}
/** The `xl:`+ context region: both participants' identity cards, a linked-task
* summary, and a no-task hint when there isn't one — read-only, never a
* second place to act on the conversation (design doc §1). */
export function A2AContextPane({
agentA,
agentB,
taskId,
}: A2AContextPaneProps) {
const { data: task, isLoading } = useTask(taskId ?? "");
return (
<div className="p-3 space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Context</span>
</div>
<div className="space-y-2">
<IdentityCard slug={agentA} />
<IdentityCard slug={agentB} />
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Linked task
</div>
{!taskId ? (
<p className="text-xs text-muted-foreground">
This conversation has no linked task
</p>
) : isLoading || !task ? (
<Skeleton className="h-16 w-full" />
) : (
<div className="rounded-lg border p-2.5 space-y-1.5">
<div className="text-sm font-medium truncate">{task.title}</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant={task.status === "completed" ? "default" : "secondary"}
className="text-xs"
>
{task.status}
</Badge>
<Link
prefetch={false}
href={`/tasks/${taskId}`}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ListTodo className="h-3 w-3" />
View task
</Link>
</div>
</div>
)}
</div>
</div>
);
}
@@ -6,51 +6,45 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getAgentDisplayName } from "@/lib/agent-utils";
import type { AdminConversationSummary } from "@/lib/api/a2a";
import { usePulseFlash } from "@/hooks/use-pulse-flash";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { ListTodo, MessagesSquare } from "lucide-react";
import { PairAvatar } from "./a2a-pair-card";
import { PAIR_PULSE_FADE_MS, pairKey } from "./a2a-switchboard-utils";
interface A2AConversationListProps {
conversations: AdminConversationSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
/** pairKey(agent_a, agent_b) -> epoch ms of the latest matching frame —
* the same map the switchboard uses, so a row flashes on the same live
* pulse as its pair's card. */
pulses: Record<string, number>;
}
export function A2AConversationList({
conversations,
selectedId,
interface ConversationRowProps {
conversation: AdminConversationSummary;
isSelected: boolean;
onSelect: (id: string) => void;
pulsedAt: number | null;
}
function ConversationRow({
conversation,
isSelected,
onSelect,
isLoading,
}: A2AConversationListProps) {
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (conversations.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No A2A conversations yet</p>
</div>
</div>
);
}
pulsedAt,
}: ConversationRowProps) {
const isPulsing = usePulseFlash(pulsedAt);
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{conversations.map((conversation) => (
<div
key={conversation.id}
role="button"
tabIndex={0}
data-testid="conversation-row"
data-pulsing={isPulsing}
onClick={() => onSelect(conversation.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -58,14 +52,24 @@ export function A2AConversationList({
onSelect(conversation.id);
}
}}
className={
"block w-full cursor-pointer p-3 rounded-lg border transition-all " +
(selectedId === conversation.id
? "bg-primary/10 border-primary"
: "bg-card hover:bg-muted/50 hover:border-primary/50")
}
className={cn(
"block w-full cursor-pointer p-3 rounded-lg border",
"transition-[background-color,box-shadow] ease-out",
isSelected ? "border-primary" : "border-border",
isPulsing
? "bg-emerald-500/15 shadow-[0_0_0_1px_rgba(16,185,129,0.6)]"
: isSelected
? "bg-primary/10"
: "bg-card hover:bg-muted/50 hover:border-primary/50",
)}
style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 min-w-0 flex-1">
<div className="flex -space-x-2 shrink-0 pt-0.5">
<PairAvatar slug={conversation.agent_a} />
<PairAvatar slug={conversation.agent_b} />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">
{getAgentDisplayName(conversation.agent_a)}
@@ -102,11 +106,10 @@ export function A2AConversationList({
</Link>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge
variant={
conversation.status === "active" ? "default" : "secondary"
}
variant={conversation.status === "active" ? "default" : "secondary"}
className="text-xs"
>
{conversation.status}
@@ -117,6 +120,51 @@ export function A2AConversationList({
</div>
</div>
</div>
);
}
export function A2AConversationList({
conversations,
selectedId,
onSelect,
isLoading,
pulses,
}: A2AConversationListProps) {
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (conversations.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No A2A conversations yet</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{conversations.map((conversation) => (
<ConversationRow
key={conversation.id}
conversation={conversation}
isSelected={selectedId === conversation.id}
onSelect={onSelect}
pulsedAt={
pulses[pairKey(conversation.agent_a, conversation.agent_b)] ??
null
}
/>
))}
</div>
</ScrollArea>
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SlidersHorizontal, X } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import {
EMPTY_A2A_FILTERS,
activeA2AFilterCount,
type A2AConversationStatus,
type A2AFilters,
} from "./a2a-filter-utils";
const STATUS_OPTIONS: { value: A2AConversationStatus; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
];
interface A2AFilterBarProps {
filters: A2AFilters;
onFiltersChange: (filters: A2AFilters) => void;
/** Distinct agent slugs to list as checkboxes (already deduped+sorted —
* see `distinctA2AAgents`). */
agentOptions: string[];
/** Task/Status/Date only narrow the List view — Switchboard pairs have no
* conversation to filter those dimensions on (design doc §1). */
view: "switchboard" | "list";
}
interface FilterChip {
key: string;
label: string;
onRemove: () => void;
}
/**
* Popover-triggered filter panel above the switchboard/list content: Agent
* (multi-select), Task (id fragment + "No linked task"), Status (toggle
* buttons) and a date range — plus the active-filter chip row and
* Clear-all. See docs/ux_ui/design/conversations-filter-control.md.
*/
export function A2AFilterBar({
filters,
onFiltersChange,
agentOptions,
view,
}: A2AFilterBarProps) {
const toggleAgent = (agent: string) => {
onFiltersChange({
...filters,
agents: filters.agents.includes(agent)
? filters.agents.filter((a) => a !== agent)
: [...filters.agents, agent],
});
};
const toggleStatus = (status: A2AConversationStatus) => {
onFiltersChange({
...filters,
statuses: filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status],
});
};
const clearAll = () => onFiltersChange(EMPTY_A2A_FILTERS);
const chips: FilterChip[] = [
...filters.agents.map((agent) => ({
key: `agent-${agent}`,
label: getAgentDisplayName(agent),
onRemove: () => toggleAgent(agent),
})),
...(filters.taskIdFragment
? [
{
key: "task-fragment",
label: `Task: ${filters.taskIdFragment}`,
onRemove: () => onFiltersChange({ ...filters, taskIdFragment: "" }),
},
]
: []),
...(filters.noLinkedTask
? [
{
key: "no-linked-task",
label: "No linked task",
onRemove: () =>
onFiltersChange({ ...filters, noLinkedTask: false }),
},
]
: []),
...filters.statuses.map((status) => ({
key: `status-${status}`,
label: STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status,
onRemove: () => toggleStatus(status),
})),
...(filters.dateFrom
? [
{
key: "date-from",
label: `From ${filters.dateFrom}`,
onRemove: () => onFiltersChange({ ...filters, dateFrom: "" }),
},
]
: []),
...(filters.dateTo
? [
{
key: "date-to",
label: `To ${filters.dateTo}`,
onRemove: () => onFiltersChange({ ...filters, dateTo: "" }),
},
]
: []),
];
const count = activeA2AFilterCount(filters);
return (
<div className="mb-2 shrink-0">
<div className="flex items-center justify-end">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
{count > 0 ? `Filters · ${count}` : "Filters"}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 max-h-[70vh] space-y-3 overflow-y-auto"
>
{view === "switchboard" && (
<p className="text-xs text-muted-foreground">
Task, Status, and Date filters apply to the Conversation List
view.
</p>
)}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">Agent</span>
{filters.agents.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => onFiltersChange({ ...filters, agents: [] })}
>
Clear
</Button>
)}
</div>
<div className="max-h-40 space-y-1 overflow-y-auto">
{agentOptions.map((agent) => (
<label
key={agent}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-muted"
>
<Checkbox
checked={filters.agents.includes(agent)}
onCheckedChange={() => toggleAgent(agent)}
/>
<span className="text-sm">
{getAgentDisplayName(agent)}
</span>
</label>
))}
</div>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Task</span>
<Input
value={filters.taskIdFragment}
onChange={(e) =>
onFiltersChange({
...filters,
taskIdFragment: e.target.value,
})
}
placeholder="Task id fragment..."
className="mt-1 h-7 text-xs"
aria-label="Task id fragment"
/>
<label className="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox
checked={filters.noLinkedTask}
onCheckedChange={(checked) =>
onFiltersChange({
...filters,
noLinkedTask: checked === true,
})
}
/>
<span className="text-sm">No linked task</span>
</label>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Status</span>
<div className="mt-1 flex items-center gap-1">
{STATUS_OPTIONS.map((opt) => (
<Button
key={opt.value}
type="button"
variant={
filters.statuses.includes(opt.value)
? "secondary"
: "outline"
}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={filters.statuses.includes(opt.value)}
onClick={() => toggleStatus(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Date range</span>
<div className="mt-1 flex items-center gap-2">
<Input
type="date"
value={filters.dateFrom}
onChange={(e) =>
onFiltersChange({ ...filters, dateFrom: e.target.value })
}
className="h-7 text-xs"
aria-label="From date"
/>
<Input
type="date"
value={filters.dateTo}
onChange={(e) =>
onFiltersChange({ ...filters, dateTo: e.target.value })
}
className="h-7 text-xs"
aria-label="To date"
/>
</div>
</div>
{count > 0 && (
<div className="flex justify-end border-t pt-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearAll}
>
Clear all
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
{chips.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<Badge key={chip.key} variant="secondary" className="gap-1">
{chip.label}
<button
type="button"
aria-label={`Remove ${chip.label} filter`}
onClick={chip.onRemove}
>
<X className="h-3 w-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearAll}
>
Clear all
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,166 @@
/**
* Pure filter helpers for the A2A page's filter control — shared by both the
* switchboard (pairs) and the classic list (conversations) so the same
* filter state narrows both views (per the per-view rules in
* docs/ux_ui/design/conversations-filter-control.md §1).
*
* All four dimensions filter client-side over the already-fetched page
* (there are no backend query params for them yet — see the design doc's
* "Future work" note).
*/
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
/** The two known `AdminConversationSummary.status` values. */
export type A2AConversationStatus = "active" | "archived";
export interface A2AFilters {
/** Selected agent slugs — a conversation/pair matches if either
* participant is selected (empty = no agent filter). */
agents: string[];
/** Free-text fragment matched against `task_id` (case-insensitive). */
taskIdFragment: string;
/** When true, also match conversations with `task_id === null`. */
noLinkedTask: boolean;
/** Selected statuses (empty = no status filter). List-view only. */
statuses: A2AConversationStatus[];
/** Inclusive lower bound, `YYYY-MM-DD` (native `<input type="date">`
* value) or `""` for unset. List-view only. */
dateFrom: string;
/** Inclusive upper bound, `YYYY-MM-DD` or `""` for unset. List-view
* only. */
dateTo: string;
}
export const EMPTY_A2A_FILTERS: A2AFilters = {
agents: [],
taskIdFragment: "",
noLinkedTask: false,
statuses: [],
dateFrom: "",
dateTo: "",
};
/** Total count of active filter values, one per chip — drives the
* trigger's `Filters · N` badge. */
export function activeA2AFilterCount(filters: A2AFilters): number {
return (
filters.agents.length +
(filters.taskIdFragment.trim() ? 1 : 0) +
(filters.noLinkedTask ? 1 : 0) +
filters.statuses.length +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0)
);
}
function matchesAgent(
agentA: string,
agentB: string,
agents: ReadonlyArray<string>,
): boolean {
if (agents.length === 0) return true;
return agents.includes(agentA) || agents.includes(agentB);
}
function matchesTask(
taskId: string | null,
fragment: string,
noLinkedTask: boolean,
): boolean {
const frag = fragment.trim().toLowerCase();
if (!frag && !noLinkedTask) return true;
const fragmentMatch = frag
? !!taskId && taskId.toLowerCase().includes(frag)
: false;
const noLinkedMatch = noLinkedTask ? taskId === null : false;
return fragmentMatch || noLinkedMatch;
}
function matchesStatus(
status: string,
statuses: ReadonlyArray<A2AConversationStatus>,
): boolean {
if (statuses.length === 0) return true;
return statuses.includes(status as A2AConversationStatus);
}
/** Day-granularity local-timezone `YYYY-MM-DD`, comparable against a native
* `<input type="date">` value. */
function localDateOnly(iso: string): string {
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function matchesDateRange(
timestamp: string | null,
dateFrom: string,
dateTo: string,
): boolean {
if (!dateFrom && !dateTo) return true;
if (!timestamp) return false;
const day = localDateOnly(timestamp);
if (dateFrom && day < dateFrom) return false;
if (dateTo && day > dateTo) return false;
return true;
}
/** Narrow conversations by all four dimensions — the classic List view. */
export function filterConversations(
conversations: ReadonlyArray<AdminConversationSummary>,
filters: A2AFilters,
): AdminConversationSummary[] {
return conversations.filter(
(conversation) =>
matchesAgent(
conversation.agent_a,
conversation.agent_b,
filters.agents,
) &&
matchesTask(
conversation.task_id,
filters.taskIdFragment,
filters.noLinkedTask,
) &&
matchesStatus(conversation.status, filters.statuses) &&
matchesDateRange(
conversation.last_message_at ?? conversation.created_at,
filters.dateFrom,
filters.dateTo,
),
);
}
/** Narrow switchboard pairs — Agent only (design doc §1 "Per-view
* applicability"): a pair with no conversation has no task/status/date to
* filter on. */
export function filterPairs(
pairs: ReadonlyArray<AdminPairSummary>,
filters: A2AFilters,
): AdminPairSummary[] {
return pairs.filter((pair) =>
matchesAgent(pair.agent_a, pair.agent_b, filters.agents),
);
}
/** Distinct agent slugs present across the currently loaded pairs +
* conversations, deduplicated and sorted — the Agent checkbox list's
* option set (design doc §1, dimension 1). */
export function distinctA2AAgents(
conversations: ReadonlyArray<AdminConversationSummary>,
pairs: ReadonlyArray<AdminPairSummary>,
): string[] {
const slugs = new Set<string>();
for (const pair of pairs) {
slugs.add(pair.agent_a);
slugs.add(pair.agent_b);
}
for (const conversation of conversations) {
slugs.add(conversation.agent_a);
slugs.add(conversation.agent_b);
}
return Array.from(slugs).sort();
}
+16 -29
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { cn } from "@/lib/utils";
import { usePulseFlash } from "@/hooks/use-pulse-flash";
import { formatDistanceToNow } from "date-fns";
import { PAIR_PULSE_FADE_MS } from "./a2a-switchboard-utils";
@@ -17,10 +22,16 @@ interface A2APairCardProps {
onOpen: () => void;
}
function PairAvatar({ slug }: { slug: string }) {
/** One agent's avatar (initials in a circle) — shared with
* A2AConversationList so a pair/conversation's two participants render
* identically across the switchboard and the classic list. */
export function PairAvatar({ slug }: { slug: string }) {
return (
<div
className="h-7 w-7 rounded-full bg-primary/10 border flex items-center justify-center shrink-0"
className={cn(
"h-7 w-7 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[getAgentTeamColor(slug)],
)}
title={getAgentDisplayName(slug)}
>
<span className="text-[9px] font-bold tracking-tight">
@@ -44,31 +55,7 @@ export function A2APairCard({
onOpen,
}: A2APairCardProps) {
const hasHistory = pair.conversation_id !== null;
const [isPulsing, setIsPulsing] = useState(false);
// Render-phase derivation, not an Effect (react.dev/learn/you-might-not-
// need-an-effect#adjusting-some-state-when-a-prop-changes): flash hot in
// the very same render that receives a new pulsedAt, comparing against the
// last value we've seen. No cascading extra render from an Effect body.
// Seeded to null (not the initial pulsedAt) so a card that *mounts*
// already carrying a live pulse — e.g. switching into switchboard view
// right after a frame arrived — still flashes hot instead of looking cold.
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
if (pulsedAt !== lastSeenPulse) {
setLastSeenPulse(pulsedAt);
if (pulsedAt !== null) setIsPulsing(true);
}
// Flip back on the next paint frame — the long CSS transition-duration
// below then animates the decay from "hot" to baseline over
// PAIR_PULSE_FADE_MS. The setState here is inside the (async) rAF
// callback, not the Effect body itself, so it's the intended "subscribe to
// an external clock" use of an Effect.
useEffect(() => {
if (!isPulsing) return;
const raf = requestAnimationFrame(() => setIsPulsing(false));
return () => cancelAnimationFrame(raf);
}, [isPulsing]);
const isPulsing = usePulseFlash(pulsedAt);
return (
<button
+178 -12
View File
@@ -1,28 +1,111 @@
"use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Markdown } from "@/components/ui/markdown";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import { cn } from "@/lib/utils";
import type { A2AChatMessage } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns";
import { MessagesSquare } from "lucide-react";
import { AlertTriangle, MessagesSquare } from "lucide-react";
interface A2ATranscriptProps {
messages: A2AChatMessage[];
isLoading: boolean;
/** False when no conversation/pair is selected at all — distinguishes
* "nothing to show yet" from "this conversation genuinely has no
* messages" (design doc §5). Defaults true (existing callers). */
hasSelection?: boolean;
/** True when the messages fetch itself failed — a scoped retry, not the
* page-level OfflineState (design doc §5). */
error?: boolean;
onRetry?: () => void;
}
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
/** How close to the bottom (px) still counts as "at the bottom" for the
* auto-scroll / new-messages-pill decision. */
const BOTTOM_THRESHOLD_PX = 48;
function EmptyState({
icon: Icon,
message,
}: {
icon: typeof MessagesSquare;
message: string;
}) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
export function A2ATranscript({
messages,
isLoading,
hasSelection = true,
error = false,
onRetry,
}: A2ATranscriptProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
const [seenIds, setSeenIds] = useState<Set<string> | null>(null);
const [newRowIds, setNewRowIds] = useState<ReadonlySet<string>>(new Set());
const [showJumpPill, setShowJumpPill] = useState(false);
const [pillEntering, setPillEntering] = useState(false);
// Chronological (oldest first) regardless of payload ordering.
const sorted = [...messages].sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
);
const currentIds = sorted.map((m) => m.id);
// Render-phase derivation (same idiom as A2APairCard's usePulseFlash — state,
// not a ref, compared against the current props): the first time a batch of
// ids appears it seeds "seen" without flagging anything new — messages
// present at initial load must render settled, never animate in. A later id
// absent from "seen" is a genuine arrival.
if (seenIds === null) {
setSeenIds(new Set(currentIds));
} else {
const freshIds = currentIds.filter((id) => !seenIds.has(id));
if (freshIds.length > 0) {
setSeenIds(new Set([...seenIds, ...freshIds]));
if (isAtBottom) {
setNewRowIds((prev) => new Set([...prev, ...freshIds]));
} else {
// Scrolled up: the new row is off-screen — surface the "New
// messages" pill instead of an invisible entrance transition.
setShowJumpPill(true);
setPillEntering(true);
}
}
}
// Settle the entrance transition one paint frame after new rows appear.
useEffect(() => {
if (newRowIds.size === 0) return;
const raf = requestAnimationFrame(() => setNewRowIds(new Set()));
return () => cancelAnimationFrame(raf);
}, [newRowIds]);
useEffect(() => {
if (!pillEntering) return;
const raf = requestAnimationFrame(() => setPillEntering(false));
return () => cancelAnimationFrame(raf);
}, [pillEntering]);
// Auto-scroll to bottom only once on initial load.
useEffect(() => {
@@ -32,6 +115,23 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
}
}, [sorted.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
setIsAtBottom(atBottom);
if (atBottom) setShowJumpPill(false);
}, []);
const scrollToBottom = useCallback(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
setShowJumpPill(false);
}, []);
if (isLoading) {
return (
<div className="p-4 space-y-4">
@@ -48,26 +148,75 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
);
}
if (sorted.length === 0) {
if (error) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No messages in this conversation yet</p>
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50 text-destructive" />
<p className="text-sm mb-3">Couldn&apos;t load this conversation</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
</div>
);
}
if (!hasSelection) {
return (
<div ref={scrollRef} className="h-full overflow-y-auto p-4">
<EmptyState
icon={MessagesSquare}
message="Select a conversation to view messages"
/>
);
}
if (sorted.length === 0) {
return (
<EmptyState
icon={MessagesSquare}
message="No messages in this conversation yet"
/>
);
}
return (
<div className="relative h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-4"
>
<div className="space-y-3">
{sorted.map((message) => (
{sorted.map((message) => {
const isNew = newRowIds.has(message.id);
const teamColor = getAgentTeamColor(message.from_agent);
return (
<div
key={message.id}
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
data-testid="transcript-row"
data-new={isNew}
className={cn(
"flex gap-3 p-3 rounded-lg border hover:bg-muted/30",
// ponytail: one shared 200ms transition covers
// opacity/transform/background so the reduced-motion
// background flash rides the same timing as the
// full-motion fade-in instead of a second bespoke duration.
"transition-[opacity,transform,background-color] duration-200 ease-out",
"motion-reduce:transition-colors motion-reduce:translate-y-0",
isNew
? "opacity-0 translate-y-1 bg-muted/50 motion-reduce:opacity-100"
: "opacity-100 translate-y-0 bg-card",
)}
>
<div
className={cn(
"h-9 w-10 rounded-lg flex items-center justify-center shrink-0 border",
TEAM_COLOR_CLASSES[teamColor],
)}
>
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(message.from_agent)}
</span>
@@ -91,8 +240,25 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
</div>
</div>
</div>
))}
);
})}
</div>
</div>
{showJumpPill && (
<button
type="button"
onClick={scrollToBottom}
className={cn(
"absolute bottom-3 left-1/2 rounded-full bg-primary text-primary-foreground text-xs px-3 py-1 shadow-md",
"transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
pillEntering
? "opacity-0 translate-x-[-50%] translate-y-1 motion-reduce:opacity-100 motion-reduce:translate-y-0"
: "opacity-100 translate-x-[-50%] translate-y-0",
)}
>
New messages
</button>
)}
</div>
);
}
+30
View File
@@ -3,6 +3,7 @@
*/
import type { A2AChatMessage } from "@/lib/api/a2a";
import type { ConnectionState } from "@/lib/websocket/connection";
/** The human CEO's fixed slug — never a valid reply target (the CEO composes
* as itself, so it can't be its own recipient). */
@@ -47,3 +48,32 @@ export function pickDefaultRecipient(
if (lastSender === agentA || lastSender === agentB) return lastSender;
return agentA;
}
/** Label for the pane-header connection badge (design doc §3). */
export function connectionStateLabel(state: ConnectionState): string {
switch (state) {
case "connected":
return "Live";
case "connecting":
return "Connecting…";
case "reconnecting":
return "Reconnecting…";
case "disconnected":
return "Offline";
}
}
/** Dot color for the pane-header connection badge — `connected` is static
* (no pulse); `connecting`/`reconnecting` share the amber pulsing family,
* guarded against `prefers-reduced-motion` (design doc §3). */
export function connectionDotClasses(state: ConnectionState): string {
switch (state) {
case "connected":
return "bg-emerald-500";
case "connecting":
case "reconnecting":
return "bg-amber-500 animate-pulse motion-reduce:animate-none";
case "disconnected":
return "bg-muted-foreground/40";
}
}
@@ -91,9 +91,9 @@ describe("pipelineStageLabel", () => {
expect(
pipelineStageLabel({ kind: "rendering", attempt: 2, maxAttempts: 5 }),
).toBe("Rendering (attempt 2/5)");
expect(
pipelineStageLabel({ kind: "render_failed", reason: "boom" }),
).toBe("Render failed: boom");
expect(pipelineStageLabel({ kind: "render_failed", reason: "boom" })).toBe(
"Render failed: boom",
);
expect(pipelineStageLabel({ kind: "render_failed", reason: null })).toBe(
"Render failed",
);
@@ -150,10 +150,12 @@ export function SocialHistorySection({ className }: { className?: string }) {
const isLoading = open && (xLoading || videoLoading);
const rows: UnifiedRow[] = [
...(xHistory ?? []).map((entry): UnifiedRow => ({ kind: "x", entry })),
...(videoHistory ?? []).map((entry): UnifiedRow => ({
...(videoHistory ?? []).map(
(entry): UnifiedRow => ({
kind: "video",
entry,
})),
}),
),
].sort(
(a, b) =>
new Date(b.entry.acted_at).getTime() -
@@ -32,7 +32,11 @@ const IN_REVIEW_STATUSES = new Set([
export function derivePipelineStage(
item: Pick<
VideoPipelineItem,
"status" | "render_status" | "render_attempts" | "max_attempts" | "render_error"
| "status"
| "render_status"
| "render_attempts"
| "max_attempts"
| "render_error"
>,
): PipelineStage {
if (item.status === "completed") {
@@ -46,7 +50,8 @@ export function derivePipelineStage(
maxAttempts: item.max_attempts,
};
}
if (item.status === "awaiting_ceo_approval") return { kind: "awaiting_approval" };
if (item.status === "awaiting_ceo_approval")
return { kind: "awaiting_approval" };
if (IN_REVIEW_STATUSES.has(item.status)) return { kind: "in_review" };
return { kind: "authoring" };
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
/**
* True for one paint frame after `pulsedAt` changes to a non-null value, then
* flips back to false — the consumer's own CSS `transition-duration` does the
* actual fade-out. Shared between A2APairCard (switchboard) and
* A2AConversationList (classic list) so both flash consistently off the same
* `pairKey -> epoch ms` pulse map.
*
* Render-phase derivation (react.dev/learn/you-might-not-need-an-effect
* #adjusting-some-state-when-a-prop-changes), not an Effect keyed on
* `pulsedAt`: flips hot in the very same render that receives a new
* `pulsedAt`, comparing against the last value seen. Seeded to `null` (not
* the initial `pulsedAt`) so a component that *mounts* already carrying a
* live pulse still flashes hot instead of looking cold.
*/
export function usePulseFlash(pulsedAt: number | null): boolean {
const [isPulsing, setIsPulsing] = useState(false);
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
if (pulsedAt !== lastSeenPulse) {
setLastSeenPulse(pulsedAt);
if (pulsedAt !== null) setIsPulsing(true);
}
// Flip back on the next paint frame — the async rAF callback is the
// intended "subscribe to an external clock" use of an Effect.
useEffect(() => {
if (!isPulsing) return;
const raf = requestAnimationFrame(() => setIsPulsing(false));
return () => cancelAnimationFrame(raf);
}, [isPulsing]);
return isPulsing;
}
@@ -3,8 +3,11 @@ import {
resolveToSlug,
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
isKnownAgent,
registerAgentRoster,
TEAM_COLOR_CLASSES,
type AgentTeamColor,
} from "@/lib/agent-utils";
// Canonical UUIDs from the backend roster (roboco/foundation/identity.py).
@@ -87,3 +90,51 @@ describe("agent-utils existing roster (regression guard)", () => {
expect(getAgentDisplayName(null)).toBe("Unassigned");
});
});
describe("getAgentTeamColor (design doc §2 — six buckets)", () => {
it.each([
["be-dev-1", "backend"],
["be-pm", "backend"],
["fe-dev-2", "frontend"],
["fe-qa", "frontend"],
["ux-dev-1", "ux_ui"],
["ux-doc", "ux_ui"],
["main-pm", "board"],
["product-owner", "board"],
["head-marketing", "board"],
["auditor", "board"],
["ceo", "ceo"],
["CEO", "ceo"],
["intake-1", "system"],
["secretary-1", "system"],
["pr-reviewer-1", "system"],
] satisfies [string, AgentTeamColor][])(
"buckets %s as %s",
(slug, expected) => {
expect(getAgentTeamColor(slug)).toBe(expected);
},
);
it("resolves a UUID to its team bucket via the slug map", () => {
expect(getAgentTeamColor(BE_DEV_1_UUID)).toBe("backend");
});
it("falls back to system for an unrecognized id, never throwing", () => {
expect(getAgentTeamColor("some-unknown-agent")).toBe("system");
expect(getAgentTeamColor(null)).toBe("system");
});
it("gives every bucket a class string with no new color families beyond the six", () => {
const buckets: AgentTeamColor[] = [
"backend",
"frontend",
"ux_ui",
"board",
"ceo",
"system",
];
for (const bucket of buckets) {
expect(TEAM_COLOR_CLASSES[bucket]).toBeTruthy();
}
});
});
+56
View File
@@ -229,3 +229,59 @@ export function isKnownAgent(agentId: string | null | undefined): boolean {
if (!agentId) return false;
return liveByKey.has(agentId) || agentId in AGENT_NAMES;
}
// ---------------------------------------------------------------------------
// Team-color identity (design doc:
// docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md
// §2). Color is scoped to the CELL an agent belongs to, not a per-agent hue —
// legible at 22-agent scale and needs no new bucket when a cell grows.
// ---------------------------------------------------------------------------
export type AgentTeamColor =
| "backend"
| "frontend"
| "ux_ui"
| "board"
| "ceo"
| "system";
/** Every value here is an existing Tailwind color family already used
* elsewhere in the codebase at the same `/15` bg + `/40` border weight
* (`a2a-pair-card.tsx`'s pulse treatment) — no new tokens introduced. */
export const TEAM_COLOR_CLASSES: Record<AgentTeamColor, string> = {
backend: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-400",
frontend:
"bg-violet-500/15 border-violet-500/40 text-violet-700 dark:text-violet-400",
ux_ui:
"bg-fuchsia-500/15 border-fuchsia-500/40 text-fuchsia-700 dark:text-fuchsia-400",
board:
"bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-400",
// The one human gets the app's own accent, not a team bucket.
ceo: "bg-primary/15 border-primary/40 text-primary",
system:
"bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400",
};
/**
* Resolve an agent id (slug or UUID) to its cell color bucket, derived from
* the slug prefix. Unknown/unresolved slugs fall back to `system` — a color
* layer is a scanning aid, never something that should throw on a stray id.
*/
export function getAgentTeamColor(
agentId: string | null | undefined,
): AgentTeamColor {
const slug = resolveToSlug(agentId);
if (slug === "ceo" || slug === "CEO") return "ceo";
if (slug.startsWith("be-")) return "backend";
if (slug.startsWith("fe-")) return "frontend";
if (slug.startsWith("ux-")) return "ux_ui";
if (
slug === "main-pm" ||
slug === "product-owner" ||
slug === "head-marketing" ||
slug === "auditor"
) {
return "board";
}
return "system";
}
+9
View File
@@ -13,11 +13,16 @@ interface UIState {
// Current context
currentTeam: Team | null;
// A2A live view: xl:+ context pane collapse (conversation-first layout
// design doc §1) — same persisted-preference idiom as sidebar/theme.
a2aContextOpen: boolean;
// Actions
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
setTheme: (theme: "light" | "dark" | "system") => void;
setCurrentTeam: (team: Team | null) => void;
toggleA2AContext: () => void;
}
export const useUIStore = create<UIState>()(
@@ -27,12 +32,15 @@ export const useUIStore = create<UIState>()(
sidebarCollapsed: false,
theme: "system",
currentTeam: null,
a2aContextOpen: true,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
setTheme: (theme) => set({ theme }),
setCurrentTeam: (team) => set({ currentTeam: team }),
toggleA2AContext: () =>
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
}),
{
name: "roboco-ui-storage",
@@ -40,6 +48,7 @@ export const useUIStore = create<UIState>()(
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
currentTeam: state.currentTeam,
a2aContextOpen: state.a2aContextOpen,
}),
},
),