mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
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
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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't
|
||||
A2A'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't be sent (A2A messages are always scoped to
|
||||
a task).
|
||||
can'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't
|
||||
A2A'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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user