feat: Agents hub — Fleet + Conversations tabs, /a2a merged in, DM quick-action (#558)

* feat(panel): Agents hub — Fleet and Conversations tabs, /a2a redirect, DM quick-action

* fix(panel): validate deep-linked DM targets against roster and exclusions; re-arm the dm latch

* docs(map): panel entries for this wave

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 07:06:32 +02:00
committed by GitHub
co-authored by Renn F
parent 1a726d4c34
commit 4480dfe8d1
15 changed files with 1885 additions and 1382 deletions
+25 -15
View File
@@ -8646,21 +8646,22 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/tasks/page.tsx` + `tasks/[taskId]/page.tsx` | Task list + task detail (tabbed) |
| `panel/src/app/(dashboard)/kanban/page.tsx` | Operator kanban (dev/qa/pm/pr-review views) |
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog to open a fresh CEO-owned 1:1, live via `/ws/system` `a2a.message` frames |
| `panel/src/app/(dashboard)/agents/page.tsx` | Agents hub: Fleet (roster/spawn/status, default) + Conversations tabs via `?tab=fleet\|conversations``AgentsFleetView` (fleet roster/spawn/activity) and `A2AView` (org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog, live via `/ws/system` `a2a.message` frames — a pure lift of the old standalone `/a2a` page body) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | Redirect shim → `/agents?tab=conversations` (A2A folded into the Agents hub's Conversations tab, wave 9) — kept so old bookmarks/links still resolve |
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(dashboard)/{business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table |
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
| `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card |
| `panel/src/components/a2a/` | a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/a2a/` | a2a-view.tsx (`A2AView` — the Conversations tab's full body, a pure lift of the old standalone `/a2a` page; owns the `?dm=` quick-action latch, see Gotchas), a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1, or preselects a validated `?dm=` deep-link target), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**) |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers; `agents/` similarly exports `agents-fleet-view.tsx` (`AgentsFleetView`) consumed by `agents/page.tsx`'s Fleet tab, plus `agent-card.tsx`'s DM quick-action button); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` / `useCreateCeoConversation` / `useSendCeoMessage` mutations; `a2aLiveKeys` query-key namespace |
@@ -8687,7 +8688,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Roadmap Review Queue | `components/dashboard/roadmap-review-queue.tsx` | CEO per-item approve (materializes BACKLOG task)/reject on the Product Owner's held roadmap cycle; hidden until authored |
| Feature Flags | `components/settings/feature-flags-card.tsx` | Toggles persisted to settings store; takes effect on next backend restart |
| Intake / MegaTask | `app/(dashboard)/prompter/page.tsx` + `components/prompter/*` | Live SSE chat with spawned Claude/Grok intake agent; single-project, product, or multi-project (`project_ids`) MegaTask → `propose_batch``confirm-batch` |
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| Agents — Fleet | `app/(dashboard)/agents/page.tsx` (`?tab=fleet`, default) + `components/agents/agents-fleet-view.tsx` + `components/agents/*` | Full agent roster grouped into Leadership/Backend/Frontend/UX-UI/Support grids: live state, spawn/stop controls, activity-stream link, token usage. Each card's DM button (hidden for `EXCLUDE_NON_DM_ROLES`) deep-links `/agents?tab=conversations&dm=<agent id>` into the Conversations tab |
| Agents — Conversations (switchboard + reply + New DM) | `app/(dashboard)/agents/page.tsx` (`?tab=conversations`) + `components/a2a/a2a-view.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed) — either from the dialog's own picker or preselected from a validated `?dm=` deep link off the Fleet tab; the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer. `/a2a` redirects here |
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
@@ -8711,12 +8713,15 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `prompterLiveApi` | module | `lib/api/prompter-live.ts` | `start/streamUrl/messages/confirm/confirmBatch`; EventSource SSE |
| `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle |
| `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the A2A page's invalidate-on-frame + switchboard pulses |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the Conversations tab's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames |
| `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success |
| `A2AView` | comp | `components/a2a/a2a-view.tsx` | Conversations tab body — pure lift of the old standalone `/a2a` page (switchboard/list, transcript, reply/direct composer, New DM); owns the `?dm=` quick-action latch (`dmParam`/`prevDmParam` render-phase state, re-arms once `dm` is stripped) and the `?conversation=` selection, both now targeting `/agents` instead of `/a2a` |
| `AgentsFleetView` | comp | `components/agents/agents-fleet-view.tsx` | Fleet tab body — pure lift of the old standalone `/agents` page (roster grids, orchestrator status, spawn/stop controls); `AgentCard`'s DM button pushes the `?dm=` deep link the Conversations tab's latch consumes |
| DM quick-action (`?dm=` deep link) | logic | `components/agents/agent-card.tsx` + `components/a2a/a2a-view.tsx` + `components/a2a/a2a-new-dm-dialog.tsx` | `AgentCard`'s DM button (hidden for `EXCLUDE_NON_DM_ROLES`) pushes `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches it open once per distinct value then strips `dm` from the URL, re-arming (`prevDmParam` reset to `null`) so a repeated identical deep link still fires instead of being silently swallowed by a stale latch; `A2ANewDmDialog` only preselects the target once it resolves against the live roster and passes `EXCLUDE_NON_DM_ROLES` — the deep link is untrusted URL input, never trusted blindly |
| `A2ASwitchboard` / `A2APairCard` | comp | `components/a2a/a2a-switchboard.tsx` + `a2a-pair-card.tsx` | Org-chart pair cards grouped into sections (cell/PM-chain/board/cross-team) via `groupPairsBySection`; each card pulses for `PAIR_PULSE_FADE_MS` (45s) after a matching live frame |
| `A2AReplyComposer` | comp | `components/a2a/a2a-reply-composer.tsx` | CEO chime-in box on a selected WATCHED conversation; disabled when it has no linked task (A2A sends require one) |
| `A2ANewDmDialog` | comp | `components/a2a/a2a-new-dm-dialog.tsx` | CEO-voiced "start a fresh 1:1" entry point; `AgentSelector` with `excludeRoles` dropping self + non-`read_a2a` roles (auditor/pr_reviewer/prompter/secretary); `useCreateCeoConversation` opens the thread and sends the first message in one call |
| `A2ANewDmDialog` | comp | `components/a2a/a2a-new-dm-dialog.tsx` | CEO-voiced "start a fresh 1:1" entry point; `AgentSelector` with `excludeRoles` dropping self + non-`read_a2a` roles (auditor/pr_reviewer/prompter/secretary); `useCreateCeoConversation` opens the thread and sends the first message in one call. A controlled `open`/`onOpenChange` pair plus `initialTarget` let the Fleet tab's DM quick-action drive it externally — `initialTarget` only preselects once validated against the live roster + the same exclusion (untrusted `?dm=` input, never trusted blindly) |
| `A2ADirectComposer` | comp | `components/a2a/a2a-direct-composer.tsx` | Composer for a conversation the CEO itself owns (`agent_a`/`agent_b === "ceo"`); posts via `useSendCeoMessage`/the plain per-conversation send route, NOT the interject-as-ceo route `A2AReplyComposer` uses — no task link required |
| `useCreateCeoConversation` / `useSendCeoMessage` | hooks | `hooks/use-a2a-live.ts` | Mutations wrapping `a2aApi.createConversation`/`sendCeoMessage`; both force `X-Agent-ID: "ceo"` (literal slug, not the UUID) on the request and invalidate the conversation list (+ that conversation's messages for the send) |
| `useUsageStore` | store | `store/usage-store.ts` | zustand: live usage snapshot, wsState, polling fallback |
@@ -8743,7 +8748,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `sortProjects` | fn | `components/projects/projects-view.tsx` | Pure client-side sort (name/cell) for the Projects card grid; same multiplier pattern, plus a `teamLabels[cell] ?? String(cell)` fallback so a backend `Team` value outside the panel's map never crashes the sort |
## Data Flow
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The Agents hub's Conversations tab (`useA2ALiveStream`) is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the tab uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
## Mermaid
```mermaid
@@ -8755,10 +8760,10 @@ graph TD
Routes --> Tasks["tasks + tasks/[id]"]
Routes --> Kanban["kanban (dev/qa/pm/pr-review)"]
Routes --> Prompter["prompter (single + MegaTask)"]
Routes --> A2ALive["a2a (switchboard + reply)"]
Routes --> AgentsHub["agents (Fleet + Conversations tabs; a2a redirects here)"]
Routes --> Settings["settings + ai-providers"]
Routes --> Workstation["workstation (Products + Projects tabs)"]
Routes --> Domain["agents/business/journals/git/kb/auditor/work-sessions/notifications"]
Routes --> Domain["business/journals/git/kb/auditor/work-sessions/notifications"]
Overview --> Dash["components/dashboard/*"]
Dash --> Release["ReleaseProposalCard"]
Dash --> Playbook["PlaybookReviewQueue"]
@@ -8789,10 +8794,11 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── tasks/page.tsx + tasks/[taskId]/page.tsx
│ ├── kanban/page.tsx (dev/qa/pm/pr-review views)
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply/direct composer + New DM)
│ ├── agents/page.tsx (Agents hub: Fleet + Conversations tabs; a2a/page.tsx now redirects here)
│ ├── a2a/page.tsx (redirect → /agents?tab=conversations)
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
│ ├── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here)
│ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
│ └── {business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
├── src/components/
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity)
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
@@ -8801,11 +8807,12 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ │ ├── shared/
│ │ └── views/ (dev/qa/pm/pr-review kanban)
│ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card)
│ ├── a2a/ (a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── a2a/ (a2a-view.tsx = Conversations tab body, pure lift of the old /a2a page; a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers)
│ ├── agents/agents-fleet-view.tsx (Fleet tab body; agent-card.tsx owns the DM quick-action button)
│ ├── projects/ products/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
├── src/hooks/
@@ -8874,9 +8881,11 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **`proxy.ts` is Next.js 16's renamed `middleware.ts`** — same file-convention contract (default export + `config.matcher`), just relocated/renamed terminology (it never ran in true Edge middleware). A reader searching the repo for `middleware.ts` will find nothing; the gate lives at `src/proxy.ts`.
- **`proxy.ts` fails OPEN, not closed** — a slow/unreachable orchestrator on the `/auth/status` probe (1500ms timeout) is treated as "cloud auth off," so the dashboard stays reachable rather than the CEO getting locked out by a transient backend hiccup. This is the deliberately safe default (off is what every deploy starts on) but means a genuinely-armed deployment with a flaky orchestrator could intermittently skip the login gate.
- **X Post Queue / Roadmap Review Queue hide when empty**, mirroring the release-proposal + playbook queues — a CEO who doesn't see the card has no signal that the underlying engine is even armed; both need `refetchInterval: 30000` to surface a newly-originated draft/cycle without a manual refresh.
- **A2A page activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **Conversations tab activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **A2A reply composer is read-only on a task-less conversation**: the backend's `reply_as_ceo` route 400s exactly when the watched conversation has no `task_id` (A2A sends always ride the gateway `send` path, which requires one) — the panel pre-empts that bounce with an explanatory message instead of letting the POST fail. Conversation `status` does NOT gate the composer; the CEO's reply lands in its own direct thread with the participant, not into the watched conversation.
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `page.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `a2a-view.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **`?dm=` deep-link target is validated, not trusted**: `A2ANewDmDialog`'s `preselectable` check resolves `initialTarget` against the live roster (`useAgentDefinitions`) AND requires the role pass `EXCLUDE_NON_DM_ROLES` — an unknown agent id, an excluded role (auditor/pr_reviewer/prompter/secretary/CEO), or a still-loading roster opens the dialog un-preselected rather than trusting whatever `?dm=` claims.
- **DM latch is one-shot-with-re-arm, not fire-once-forever**: `A2AViewContent` compares `dmParam` against a `prevDmParam` render-phase snapshot to open the dialog on a NEW value, then an effect strips `dm` from the URL; once stripped, `prevDmParam` resets to `null` so a second, identical `?dm=<same agent>` deep link (re-click the same card's DM button, a re-pasted URL) still re-opens the dialog instead of silently no-op'ing against the stale latch.
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top**`WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
@@ -8915,6 +8924,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `dd4cb7f1` Wave 4: Workstation page (PR #549, 2026-07-17) — Products + Projects merged into one `/workstation?tab=` page (Products first); content extracted byte-faithfully into `components/products/products-view.tsx` + `components/projects/projects-view.tsx`; `products/page.tsx` + `projects/page.tsx` become redirect shims; the two sidebar entries collapse into one "Workstation" entry (`Briefcase` icon); `task-metadata.tsx`'s project-card link retargets to `/workstation?tab=projects`; `ProjectsView`'s q/cell/inactive filters move from URL params to local `useState` (scroll-bounce prevention, trades away shareable filtered-view links).
> - `49a87c2b`+`9a33efba` (2026-07-18, PR #556, Workstation card grids) — new `product-card-grid.tsx`/`project-card-grid.tsx` (same intrinsic `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]` sizing as `agent-grid.tsx`), reusing `CellsList`/`ProgressCell` (newly exported from `product-table.tsx`) and `getExternalUrl`/badge renderers (newly exported from `project-table.tsx`); each Workstation surface gains a Cards\|Table toggle (default Cards, persisted per-surface via `ui-store`'s new `productsView`/`projectsView`) plus a name/cell(-count) sort control that only applies in card view — the table keeps its own unsorted render. `sortProducts`/`sortProjects` (both exported for direct unit tests) use a direction multiplier rather than sort-then-reverse, since reversing also flips the relative order of ties, and `sortProjects`' cell-key comparator falls back to the raw `assigned_cell` value for a backend `Team` outside the panel's `teamLabels` map instead of crashing; the fix commit tightened both to this shape after adversarial review caught the initial sort-then-reverse cut.
> - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`.
> - `431cb2ae`+`70e53bc1` (2026-07-18, branch `feature/wave-9-agents-hub`, PR #558) — Agents hub: `/agents` gains Fleet + Conversations tabs (`?tab=`, Fleet default). The entire former `/a2a` page body moves into `components/a2a/a2a-view.tsx` (`A2AView`, a pure lift — its own `?conversation=` param keeps working, every writer now targets `/agents` preserving the rest of the query string) and a new `components/agents/agents-fleet-view.tsx` (`AgentsFleetView`) holds the old `/agents` roster body; `a2a/page.tsx` becomes a redirect shim → `/agents?tab=conversations`; the sidebar's standalone A2A nav entry is removed (folded into "Agents"). `AgentCard` gains a DM quick-action button (hidden for `EXCLUDE_NON_DM_ROLES`) pushing `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches the `?dm=` param open once per distinct value and strips it from the URL. Follow-up fix (`70e53bc1`): the deep-linked target was being trusted blindly — `A2ANewDmDialog` now only preselects it once validated against the live roster + `EXCLUDE_NON_DM_ROLES`, and the latch re-arms once `dm` clears so a repeated identical deep link still fires.
## Regression Risks
+27 -17
View File
@@ -15,10 +15,11 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/tasks/page.tsx` + `tasks/[taskId]/page.tsx` | Task list + task detail (tabbed) |
| `panel/src/app/(dashboard)/kanban/page.tsx` | Operator kanban (dev/qa/pm/pr-review views) |
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog to open a fresh CEO-owned 1:1, live via `/ws/system` `a2a.message` frames |
| `panel/src/app/(dashboard)/agents/page.tsx` | Agents hub: Fleet (roster/spawn/status, default) + Conversations tabs via `?tab=fleet\|conversations``AgentsFleetView` (fleet roster/spawn/activity) and `A2AView` (org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog, live via `/ws/system` `a2a.message` frames — a pure lift of the old standalone `/a2a` page body) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | Redirect shim → `/agents?tab=conversations` (A2A folded into the Agents hub's Conversations tab, wave 9) — kept so old bookmarks/links still resolve |
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(dashboard)/{business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/app/(tg)/layout.tsx` + `(tg)/tg/page.tsx` | Telegram Mini App cockpit at `/tg`: slim shell (no sidebar/header, `next/script` loads the Telegram WebApp bridge `afterInteractive`) + bootstrap page that resolves `window.Telegram.WebApp`, POSTs its `initData` to `/telegram/webapp-auth` unconditionally, then renders the tabbed cockpit (or an "Open from Telegram" / error state) |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present; matcher excludes `tg(?:/|$)` — the Mini App authenticates via Telegram `initData`, not the password cookie, so it must never be redirected to `/login` |
@@ -26,13 +27,13 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table |
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
| `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card |
| `panel/src/components/a2a/` | a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1; exports `EXCLUDE_NON_DM_ROLES` so `tg-chat-tab.tsx`'s compose picker shares the same non-DM-capable-role exclusion instead of drifting out of sync), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/a2a/` | a2a-view.tsx (`A2AView` — the Conversations tab's full body, a pure lift of the old standalone `/a2a` page; owns the `?dm=` quick-action latch, see Gotchas), a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in on a watched conversation), a2a-new-dm-dialog (CEO opens a fresh 1:1, or preselects a validated `?dm=` deep-link target; exports `EXCLUDE_NON_DM_ROLES` so `tg-chat-tab.tsx`'s compose picker shares the same non-DM-capable-role exclusion instead of drifting out of sync), a2a-direct-composer (CEO's own thread, no task link required), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**), `mobile-task-board.tsx` (read-only, grouped-by-status phone board for the `/tg` cockpit) |
| `panel/src/components/tg/` | The `/tg` cockpit's own tabs: `tg-tab-bar.tsx` (4-tab bottom nav, page-state-controlled), `tg-approvals-tab.tsx` (stacks the existing held-artifact queue cards), `tg-inbox-tab.tsx` (notifications + ack), `tg-board-tab.tsx` (wraps `mobile-task-board.tsx`), `tg-chat-tab.tsx` (A2A conversation list / compose / polled thread, phone-scoped rebuild of the desktop A2A page) |
| `panel/src/components/tg/` | The `/tg` cockpit's own tabs: `tg-tab-bar.tsx` (4-tab bottom nav, page-state-controlled), `tg-approvals-tab.tsx` (stacks the existing held-artifact queue cards), `tg-inbox-tab.tsx` (notifications + ack), `tg-board-tab.tsx` (wraps `mobile-task-board.tsx`), `tg-chat-tab.tsx` (A2A conversation list / compose / polled thread, phone-scoped rebuild of the Agents hub's Conversations tab) |
| `panel/src/lib/telegram/webapp.ts` | Thin typed wrapper over the global `window.Telegram.WebApp` (`ready`/`expand`/`initData`); `waitForTelegramWebApp` polls (100ms, 1.5s timeout) for the CDN script since it loads `afterInteractive` |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers; `agents/` similarly exports `agents-fleet-view.tsx` (`AgentsFleetView`) consumed by `agents/page.tsx`'s Fleet tab, plus `agent-card.tsx`'s DM quick-action button); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` / `useCreateCeoConversation` / `useSendCeoMessage` mutations; `a2aLiveKeys` query-key namespace |
@@ -59,7 +60,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Roadmap Review Queue | `components/dashboard/roadmap-review-queue.tsx` | CEO per-item approve (materializes BACKLOG task)/reject on the Product Owner's held roadmap cycle; hidden until authored |
| Feature Flags | `components/settings/feature-flags-card.tsx` | Toggles persisted to settings store; takes effect on next backend restart |
| Intake / MegaTask | `app/(dashboard)/prompter/page.tsx` + `components/prompter/*` | Live SSE chat with spawned Claude/Grok intake agent; single-project, product, or multi-project (`project_ids`) MegaTask → `propose_batch``confirm-batch` |
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| Agents — Fleet | `app/(dashboard)/agents/page.tsx` (`?tab=fleet`, default) + `components/agents/agents-fleet-view.tsx` + `components/agents/*` | Full agent roster grouped into Leadership/Backend/Frontend/UX-UI/Support grids: live state, spawn/stop controls, activity-stream link, token usage. Each card's DM button (hidden for `EXCLUDE_NON_DM_ROLES`) deep-links `/agents?tab=conversations&dm=<agent id>` into the Conversations tab |
| Agents — Conversations (switchboard + reply + New DM) | `app/(dashboard)/agents/page.tsx` (`?tab=conversations`) + `components/a2a/a2a-view.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed) — either from the dialog's own picker or preselected from a validated `?dm=` deep link off the Fleet tab; the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer. `/a2a` redirects here |
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
@@ -84,12 +86,15 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `prompterLiveApi` | module | `lib/api/prompter-live.ts` | `start/streamUrl/messages/confirm/confirmBatch`; EventSource SSE |
| `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle |
| `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the A2A page's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames; `useA2AMessages` takes an optional `{ refetchInterval }` (default off — the desktop A2A page relies on WS invalidation) that `tg-chat-tab.tsx` sets to ~10s since the `/tg` cockpit has no WS wiring |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the Conversations tab's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames; `useA2AMessages` takes an optional `{ refetchInterval }` (default off — the desktop Conversations tab relies on WS invalidation) that `tg-chat-tab.tsx` sets to ~10s since the `/tg` cockpit has no WS wiring |
| `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success |
| `A2AView` | comp | `components/a2a/a2a-view.tsx` | Conversations tab body — pure lift of the old standalone `/a2a` page (switchboard/list, transcript, reply/direct composer, New DM); owns the `?dm=` quick-action latch (`dmParam`/`prevDmParam` render-phase state, re-arms once `dm` is stripped) and the `?conversation=` selection, both now targeting `/agents` instead of `/a2a` |
| `AgentsFleetView` | comp | `components/agents/agents-fleet-view.tsx` | Fleet tab body — pure lift of the old standalone `/agents` page (roster grids, orchestrator status, spawn/stop controls); `AgentCard`'s DM button pushes the `?dm=` deep link the Conversations tab's latch consumes |
| DM quick-action (`?dm=` deep link) | logic | `components/agents/agent-card.tsx` + `components/a2a/a2a-view.tsx` + `components/a2a/a2a-new-dm-dialog.tsx` | `AgentCard`'s DM button (hidden for `EXCLUDE_NON_DM_ROLES`) pushes `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches it open once per distinct value then strips `dm` from the URL, re-arming (`prevDmParam` reset to `null`) so a repeated identical deep link still fires instead of being silently swallowed by a stale latch; `A2ANewDmDialog` only preselects the target once it resolves against the live roster and passes `EXCLUDE_NON_DM_ROLES` — the deep link is untrusted URL input, never trusted blindly |
| `A2ASwitchboard` / `A2APairCard` | comp | `components/a2a/a2a-switchboard.tsx` + `a2a-pair-card.tsx` | Org-chart pair cards grouped into sections (cell/PM-chain/board/cross-team) via `groupPairsBySection`; each card pulses for `PAIR_PULSE_FADE_MS` (45s) after a matching live frame |
| `A2AReplyComposer` | comp | `components/a2a/a2a-reply-composer.tsx` | CEO chime-in box on a selected WATCHED conversation; disabled when it has no linked task (A2A sends require one) |
| `A2ANewDmDialog` | comp | `components/a2a/a2a-new-dm-dialog.tsx` | CEO-voiced "start a fresh 1:1" entry point; `AgentSelector` with `excludeRoles` dropping self + non-`read_a2a` roles (auditor/pr_reviewer/prompter/secretary); `useCreateCeoConversation` opens the thread and sends the first message in one call |
| `A2ANewDmDialog` | comp | `components/a2a/a2a-new-dm-dialog.tsx` | CEO-voiced "start a fresh 1:1" entry point; `AgentSelector` with `excludeRoles` dropping self + non-`read_a2a` roles (auditor/pr_reviewer/prompter/secretary); `useCreateCeoConversation` opens the thread and sends the first message in one call. A controlled `open`/`onOpenChange` pair plus `initialTarget` let the Fleet tab's DM quick-action drive it externally — `initialTarget` only preselects once validated against the live roster + the same exclusion (untrusted `?dm=` input, never trusted blindly) |
| `A2ADirectComposer` | comp | `components/a2a/a2a-direct-composer.tsx` | Composer for a conversation the CEO itself owns (`agent_a`/`agent_b === "ceo"`); posts via `useSendCeoMessage`/the plain per-conversation send route, NOT the interject-as-ceo route `A2AReplyComposer` uses — no task link required |
| `useCreateCeoConversation` / `useSendCeoMessage` | hooks | `hooks/use-a2a-live.ts` | Mutations wrapping `a2aApi.createConversation`/`sendCeoMessage`; both force `X-Agent-ID: "ceo"` (literal slug, not the UUID) on the request and invalidate the conversation list (+ that conversation's messages for the send) |
| `useUsageStore` | store | `store/usage-store.ts` | zustand: live usage snapshot, wsState, polling fallback |
@@ -116,7 +121,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `sortProjects` | fn | `components/projects/projects-view.tsx` | Pure client-side sort (name/cell) for the Projects card grid; same multiplier pattern, plus a `teamLabels[cell] ?? String(cell)` fallback so a backend `Team` value outside the panel's map never crashes the sort |
## Data Flow
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The Agents hub's Conversations tab (`useA2ALiveStream`) is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the tab uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
## Mermaid
```mermaid
@@ -128,10 +133,10 @@ graph TD
Routes --> Tasks["tasks + tasks/[id]"]
Routes --> Kanban["kanban (dev/qa/pm/pr-review)"]
Routes --> Prompter["prompter (single + MegaTask)"]
Routes --> A2ALive["a2a (switchboard + reply)"]
Routes --> AgentsHub["agents (Fleet + Conversations tabs; a2a redirects here)"]
Routes --> Settings["settings + ai-providers"]
Routes --> Workstation["workstation (Products + Projects tabs)"]
Routes --> Domain["agents/business/journals/git/kb/auditor/work-sessions/notifications"]
Routes --> Domain["business/journals/git/kb/auditor/work-sessions/notifications"]
Overview --> Dash["components/dashboard/*"]
Dash --> Release["ReleaseProposalCard"]
Dash --> Playbook["PlaybookReviewQueue"]
@@ -165,10 +170,11 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── tasks/page.tsx + tasks/[taskId]/page.tsx
│ ├── kanban/page.tsx (dev/qa/pm/pr-review views)
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply/direct composer + New DM)
│ ├── agents/page.tsx (Agents hub: Fleet + Conversations tabs; a2a/page.tsx now redirects here)
│ ├── a2a/page.tsx (redirect → /agents?tab=conversations)
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
│ ├── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here)
│ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
│ └── {business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
├── src/components/
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity)
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
@@ -177,12 +183,13 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ │ ├── shared/
│ │ └── views/ (dev/qa/pm/pr-review kanban)
│ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card)
│ ├── a2a/ (a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── a2a/ (a2a-view.tsx = Conversations tab body, pure lift of the old /a2a page; a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-new-dm-dialog, a2a-direct-composer, a2a-utils)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings; mobile-task-board.tsx for /tg)
│ ├── tg/ (tg-tab-bar, tg-approvals-tab, tg-inbox-tab, tg-board-tab, tg-chat-tab — the /tg cockpit's own tabs)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers)
│ ├── agents/agents-fleet-view.tsx (Fleet tab body; agent-card.tsx owns the DM quick-action button)
│ ├── projects/ products/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
├── src/hooks/
@@ -254,9 +261,11 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **`proxy.ts`'s `/tg` exclusion must be anchored** (`tg(?:/|$)`, not a bare `tg`) — an unanchored `tg` in the negative-lookahead matcher would also skip gating on any unrelated route that merely starts with "tg", not just the Mini App; fixed same-PR (8d727785) alongside the initData far-future rejection.
- **`/tg`'s bootstrap POSTs `initData` to `/telegram/webapp-auth` on every mount**, not just cold loads — there's no client-readable signal (the session cookie is httponly) to know a warm reload already has a valid cookie, so `tg/page.tsx` always re-validates; the route is idempotent (re-mints the same cookie) so this is cheap by design, not an oversight.
- **X Post Queue / Roadmap Review Queue hide when empty**, mirroring the release-proposal + playbook queues — a CEO who doesn't see the card has no signal that the underlying engine is even armed; both need `refetchInterval: 30000` to surface a newly-originated draft/cycle without a manual refresh.
- **A2A page activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **Conversations tab activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **A2A reply composer is read-only on a task-less conversation**: the backend's `reply_as_ceo` route 400s exactly when the watched conversation has no `task_id` (A2A sends always ride the gateway `send` path, which requires one) — the panel pre-empts that bounce with an explanatory message instead of letting the POST fail. Conversation `status` does NOT gate the composer; the CEO's reply lands in its own direct thread with the participant, not into the watched conversation.
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `page.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `a2a-view.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
- **`?dm=` deep-link target is validated, not trusted**: `A2ANewDmDialog`'s `preselectable` check resolves `initialTarget` against the live roster (`useAgentDefinitions`) AND requires the role pass `EXCLUDE_NON_DM_ROLES` — an unknown agent id, an excluded role (auditor/pr_reviewer/prompter/secretary/CEO), or a still-loading roster opens the dialog un-preselected rather than trusting whatever `?dm=` claims.
- **DM latch is one-shot-with-re-arm, not fire-once-forever**: `A2AViewContent` compares `dmParam` against a `prevDmParam` render-phase snapshot to open the dialog on a NEW value, then an effect strips `dm` from the URL; once stripped, `prevDmParam` resets to `null` so a second, identical `?dm=<same agent>` deep link (re-click the same card's DM button, a re-pasted URL) still re-opens the dialog instead of silently no-op'ing against the stale latch.
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top** — `WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
@@ -296,6 +305,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) — new `(tg)` route group (`layout.tsx` + `tg/page.tsx`) and `components/tg/{tg-tab-bar,tg-approvals-tab,tg-inbox-tab,tg-board-tab,tg-chat-tab}.tsx`; new `components/tasks/mobile-task-board.tsx` (read-only, grouped-by-status) and `lib/telegram/webapp.ts` (WebApp bridge + `waitForTelegramWebApp` poll); `a2a-new-dm-dialog.tsx` exports `EXCLUDE_NON_DM_ROLES` for `tg-chat-tab.tsx` to reuse; `use-a2a-live.ts`'s `useA2AMessages` gains an optional `refetchInterval` (the cockpit polls instead of using WS); `proxy.ts` matcher excludes `tg(?:/|$)` (anchored in the fix commit — see Gotchas). Backend companion: `POST /api/telegram/webapp-auth` — see `docs/map/api-routes-schemas.md`.
> - `49a87c2b`+`9a33efba` (2026-07-18, PR #556, Workstation card grids) — new `product-card-grid.tsx`/`project-card-grid.tsx` (same intrinsic `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]` sizing as `agent-grid.tsx`), reusing `CellsList`/`ProgressCell` (newly exported from `product-table.tsx`) and `getExternalUrl`/badge renderers (newly exported from `project-table.tsx`); each Workstation surface gains a Cards\|Table toggle (default Cards, persisted per-surface via `ui-store`'s new `productsView`/`projectsView`) plus a name/cell(-count) sort control that only applies in card view — the table keeps its own unsorted render. `sortProducts`/`sortProjects` (both exported for direct unit tests) use a direction multiplier rather than sort-then-reverse, since reversing also flips the relative order of ties, and `sortProjects`' cell-key comparator falls back to the raw `assigned_cell` value for a backend `Team` outside the panel's `teamLabels` map instead of crashing; the fix commit tightened both to this shape after adversarial review caught the initial sort-then-reverse cut.
> - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`.
> - `431cb2ae`+`70e53bc1` (2026-07-18, branch `feature/wave-9-agents-hub`, PR #558) — Agents hub: `/agents` gains Fleet + Conversations tabs (`?tab=`, Fleet default). The entire former `/a2a` page body moves into `components/a2a/a2a-view.tsx` (`A2AView`, a pure lift — its own `?conversation=` param keeps working, every writer now targets `/agents` preserving the rest of the query string) and a new `components/agents/agents-fleet-view.tsx` (`AgentsFleetView`) holds the old `/agents` roster body; `a2a/page.tsx` becomes a redirect shim → `/agents?tab=conversations`; the sidebar's standalone A2A nav entry is removed (folded into "Agents"). `AgentCard` gains a DM quick-action button (hidden for `EXCLUDE_NON_DM_ROLES`) pushing `/agents?tab=conversations&dm=<agent id>`; `A2AViewContent` latches the `?dm=` param open once per distinct value and strips it from the URL. Follow-up fix (`70e53bc1`): the deep-linked target was being trusted blindly — `A2ANewDmDialog` now only preselects it once validated against the live roster + `EXCLUDE_NON_DM_ROLES`, and the latch re-arms once `dm` clears so a repeated identical deep link still fires.
## Regression Risks
@@ -1,472 +1,13 @@
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 {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
import { describe, it, expect, vi } from "vitest";
const {
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2AAdminPairs: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams("conversation=conv-1"),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
}));
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
// Select — irrelevant to this suite, stub it out like create-task-dialog's
// suite does for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
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 {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect }));
import A2APage from "../page";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
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: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 2,
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2APage", () => {
beforeEach(() => {
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(withPageRefresh(<A2APage />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("shows the switchboard by default with pair cards grouped into sections", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
it("toggles to the classic conversation list and back", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("Conversations")).toBeInTheDocument();
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2APage />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2APage />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
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("renders the New DM trigger in the header", () => {
render(withPageRefresh(<A2APage />));
expect(
screen.getByRole("button", { name: /new dm/i }),
).toBeInTheDocument();
});
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
// A CEO-initiated DM has no task link and no picker — it must render
// A2ADirectComposer, not the task-gated A2AReplyComposer.
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
agent_a: "ceo",
agent_b: "be-dev-1",
task_id: null,
}),
],
total: 1,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/no linked task, so a reply can't be sent/i),
).not.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();
it("redirects to the Agents hub's Conversations tab", () => {
A2APage();
expect(redirect).toHaveBeenCalledWith("/agents?tab=conversations");
});
});
+4 -580
View File
@@ -1,583 +1,7 @@
"use client";
import { redirect } from "next/navigation";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AAdminPairs,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { usePageRefresh } from "@/hooks";
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 { A2ADirectComposer } from "@/components/a2a/a2a-direct-composer";
import { A2ANewDmDialog } from "@/components/a2a/a2a-new-dm-dialog";
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 { HelpTip } from "@/components/ui/help-tip";
import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { CEO_SLUG, lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Radio,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
type A2AView = "switchboard" | "list";
interface PeekedPair {
agent_a: string;
agent_b: string;
}
function A2APageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const selectedId = searchParams.get("conversation");
// Desktop default is the switchboard (org-chart pair cards); the classic
// list stays one click away as the mobile/compact fallback.
const [view, setView] = useState<A2AView>("switchboard");
// A pair with no conversation yet, clicked from the switchboard — there is
// nothing to select via `?conversation=`, so it's tracked separately and
// 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,
error,
refetch: refetchConversations,
} = useA2AConversations(100);
const {
data: pairsData,
isLoading: loadingPairs,
refetch: refetchPairs,
} = useA2AAdminPairs();
const {
data: messagesData,
isLoading: loadingMessages,
error: messagesError,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchConversations();
},
() => {
void refetchPairs();
},
() => {
void refetchMessages();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchConversations,
refetchPairs,
refetchMessages,
]);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `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,
state: connectionState,
} = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.pairs });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
});
}
}, [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
// fire (prevConnected starts unknown, not false).
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
if (prevConnected.current === false && isConnected) {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
}
prevConnected.current = isConnected;
}, [isConnected, queryClient]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
const pulses = useMemo(
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const filteredPairs = useMemo(
() => filterPairs(pairs, filters),
[pairs, filters],
);
const handleSelect = useCallback(
(id: string) => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/a2a?${params.toString()}`);
},
[router, searchParams],
);
const handleOpenPair = useCallback(
(pair: AdminPairSummary) => {
if (pair.conversation_id) {
handleSelect(pair.conversation_id);
return;
}
// Never-talked pair: nothing to select, clear any prior selection and
// show the pair's own empty state instead.
setPeekedPair({ agent_a: pair.agent_a, agent_b: pair.agent_b });
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
},
[handleSelect, router, searchParams],
);
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);
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
}, [router, searchParams]);
return (
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
<p className="text-muted-foreground">
Live agent-to-agent conversations watch and chime in
</p>
</div>
<div className="flex items-center gap-4">
<A2ANewDmDialog onCreated={handleSelect} />
<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). */}
<HelpTip
label={contextOpen ? "Hide context panel" : "Show context panel"}
>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
aria-label={
contextOpen ? "Hide context panel" : "Show context panel"
}
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" />
)}
</Button>
</HelpTip>
</div>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<HelpTip label="Returns to the switchboard/list">
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</HelpTip>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
contextOpen && "xl:col-span-3",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
aria-label="Switchboard: org-chart pair cards"
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
aria-label="Classic conversation list"
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div>
<A2AFilterBar
filters={filters}
onFiltersChange={setFilters}
agentOptions={agentOptions}
view={view}
/>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={filteredPairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={filteredConversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
pulses={pulses}
/>
)}
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<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">
{peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<>
{selected && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<HelpTip label={selected.status === "active" ? "Actively exchanging messages" : "No longer active"}>
<Badge
variant={
selected.status === "active"
? "default"
: "secondary"
}
className="text-xs w-fit"
>
{selected.status}
</Badge>
</HelpTip>
<HelpTip label={new Date(selected.updated_at).toLocaleString()}>
<span className="text-xs text-muted-foreground ml-auto w-fit">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
ago
</span>
</HelpTip>
</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>
{/* Composer: a conversation the CEO itself owns (opened
via "New DM") always gets the direct composer — it's
the CEO's own thread, not something being watched, so
no task link is required. Otherwise this is a watched
agent<->agent conversation: the backend's reply route
rejects with 400 exactly when it has no task link
(replies ride the gateway send path, which requires
one), so a task-less one is read-only — say why instead
of letting the send bounce. Status does NOT gate either
composer: a reply lands in the CEO's own direct thread
with the participant, not in the watched conversation. */}
{selected && (
<div className="shrink-0 border-t -mx-3">
{selected.agent_a === CEO_SLUG ||
selected.agent_b === CEO_SLUG ? (
<A2ADirectComposer
key={selected.id}
conversationId={selected.id}
otherAgent={
selected.agent_a === CEO_SLUG
? selected.agent_b
: selected.agent_a
}
/>
) : selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped
to a task).
</div>
)}
</div>
)}
</>
)}
</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>
</>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
// A2A merged into the Agents hub as its Conversations tab (CEO decision,
// wave 9) — this route now just forwards old links/bookmarks.
export default function A2APage() {
return (
<Suspense
fallback={
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-4">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-8" />
</div>
</div>
}
>
<A2APageContent />
</Suspense>
);
redirect("/agents?tab=conversations");
}
@@ -1,130 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// CEO feedback round 2: Total Agents must reflect the full roster, not the
// orchestrator's live-instance count, and Board + Main PM must fold into one
// "Leadership" band instead of a lone Main PM card wasting a full row.
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
// The two tab panes have their own dedicated tests — stub them here so this
// page test only checks tab composition + the URL-driven default, mirroring
// workstation/__tests__/page.test.tsx.
vi.mock("@/components/agents/agents-fleet-view", () => ({
AgentsFleetView: () => <div>AgentsFleetViewStub</div>,
}));
vi.mock("@/components/a2a/a2a-view", () => ({
A2AView: () => <div>A2AViewStub</div>,
}));
vi.mock("@/hooks/use-usage", () => ({
useAgentUsage: () => ({ data: [] }),
}));
const { useOrchestratorStatus, useWaitingAgents, useAgentDefinitions } =
vi.hoisted(() => ({
useOrchestratorStatus: vi.fn(),
useWaitingAgents: vi.fn(),
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
}));
vi.mock("@/components/agents", () => ({
OrchestratorStatusCards: ({ rosterCount }: { rosterCount: number }) => (
<div data-testid="orchestrator-status-cards" data-roster-count={rosterCount} />
),
WaitingAgentsAlert: () => <div data-testid="waiting-agents-alert" />,
AgentGrid: ({
title,
agents,
}: {
title: string;
agents: AgentDefinition[];
}) => (
<div data-testid={"grid-" + title}>
{agents.map((a) => a.id).join(",")}
</div>
),
const mockReplace = vi.fn();
let searchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => searchParams,
}));
import AgentsPage from "../page";
const AGENTS: AgentDefinition[] = [
{
id: "product-owner",
name: "Product Owner",
role: "product_owner" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "head-marketing",
name: "Head of Marketing",
role: "head_marketing" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "auditor",
name: "Auditor",
role: "auditor" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "main-pm",
name: "Main PM",
role: "main_pm" as AgentDefinition["role"],
team: "main_pm" as AgentDefinition["team"],
},
{
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer" as AgentDefinition["role"],
team: "backend" as AgentDefinition["team"],
},
];
describe("AgentsPage", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
useOrchestratorStatus.mockReturnValue({
data: {
total_agents: 2, // deliberately far below the roster size
by_state: { active: 2 },
waiting_count: 0,
agents: [],
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
useWaitingAgents.mockReturnValue({ data: undefined });
searchParams = new URLSearchParams();
mockReplace.mockClear();
});
it("passes the full roster size as the truthful Total Agents count, not the backend's live-instance total", () => {
it("defaults to the Fleet tab when the URL carries no ?tab", () => {
render(<AgentsPage />);
const cards = screen.getByTestId("orchestrator-status-cards");
expect(cards).toHaveAttribute("data-roster-count", "5");
});
it("folds Board and Main PM into one Leadership group instead of separate sections", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Leadership")).toHaveTextContent(
"product-owner,head-marketing,auditor,main-pm",
expect(screen.getByRole("tab", { name: "Fleet" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.queryByTestId("grid-Board")).not.toBeInTheDocument();
expect(screen.queryByTestId("grid-Main PM")).not.toBeInTheDocument();
});
it("still renders the per-cell grids", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Backend Cell")).toHaveTextContent(
"be-dev-1",
expect(screen.getByRole("tab", { name: "Conversations" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("AgentsFleetViewStub")).toBeInTheDocument();
});
it("does not render the Support grid when no support agents match", () => {
it("activates the Conversations tab from ?tab=conversations", () => {
searchParams = new URLSearchParams("tab=conversations");
render(<AgentsPage />);
expect(screen.queryByTestId("grid-Support")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Conversations" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "Fleet" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("A2AViewStub")).toBeInTheDocument();
});
});
+95 -142
View File
@@ -1,159 +1,112 @@
"use client";
import { useEffect, useMemo } from "react";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
import { useAgentUsage } from "@/hooks/use-usage";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
getSupportAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
WaitingAgentsAlert,
AgentGrid,
} from "@/components/agents";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentsFleetView } from "@/components/agents/agents-fleet-view";
import { A2AView } from "@/components/a2a/a2a-view";
export default function AgentsPage() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
const { data: usageRows } = useAgentUsage();
// ---------------------------------------------------------------------------
// Valid tab values
// ---------------------------------------------------------------------------
const { register, unregister, refresh } = usePageRefresh();
interface TabDef {
value: "fleet" | "conversations";
label: string;
hint: string;
}
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const TAB_DEFS: TabDef[] = [
{
value: "fleet",
label: "Fleet",
hint: "Every agent's live state, spawn controls, and activity stream",
},
{
value: "conversations",
label: "Conversations",
hint: "Live agent-to-agent message switchboard and history",
},
];
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number];
// Convert agents array to a record keyed by agent_id for easy lookup
const agentStatuses = useMemo(() => {
const result: Record<string, AgentStatusResponse> = {};
if (status?.agents) {
for (const agent of status.agents) {
result[agent.agent_id] = agent;
}
}
return result;
}, [status]);
function isValidTab(value: string | null): value is TabValue {
return TAB_VALUES.includes(value as TabValue);
}
// Convert usage rows to a record keyed by agent_slug
const agentUsageMap = useMemo(() => {
const result: Record<string, AgentUsageRow> = {};
for (const row of usageRows ?? []) {
result[row.agent_slug] = row;
}
return result;
}, [usageRows]);
// ---------------------------------------------------------------------------
// Inner component that reads URL params
// ---------------------------------------------------------------------------
function AgentsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const rawTab = searchParams.get("tab");
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "fleet";
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`/agents?${params.toString()}`);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
<p className="text-muted-foreground">
Monitor and control your AI workforce
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{TAB_DEFS.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
own data-state; re-assert the real selection state
explicitly (see task-detail/task-tabs.tsx) so the
data-[state=active] styling still fires. */}
<TabsTrigger
value={tab.value}
data-state={tab.value === activeTab ? "active" : "inactive"}
>
{tab.label}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Status Overview — Total Agents is the full roster size, not the
orchestrator's live-instance count, so it stays truthful even
when most of the roster isn't currently spawned. */}
<OrchestratorStatusCards
status={status}
isLoading={isLoading}
rosterCount={agents.length}
rosterLoading={agentsLoading}
/>
<TabsContent value="fleet" className="mt-4">
<AgentsFleetView />
</TabsContent>
{/* Waiting Agents Alert */}
{waitingAgents && (
<WaitingAgentsAlert waitingAgents={waitingAgents} />
)}
</>
)}
<TabsContent value="conversations" className="mt-4">
<A2AView />
</TabsContent>
</Tabs>
);
}
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
one Leadership band so a lone Main PM card never wastes a full row. */}
<AgentGrid
title="Leadership"
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
// ---------------------------------------------------------------------------
// Page export — wraps in Suspense for useSearchParams
// ---------------------------------------------------------------------------
<AgentGrid
title="Backend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Frontend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="UX/UI Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
and the root PR Reviewer — only rendered when at least one matches */}
{getSupportAgents(agents).length > 0 && (
<AgentGrid
title="Support"
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
agents={getSupportAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
)}
</div>
export default function AgentsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<AgentsPageContent />
</Suspense>
);
}
@@ -4,6 +4,15 @@ import React from "react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-agents", () => ({
useAgentDefinitions: () => ({
data: [
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
{ id: "auditor", name: "Auditor", role: "auditor", team: "board" },
],
}),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
useCreateCeoConversation: () => ({ mutate, isPending: false }),
}));
@@ -100,4 +109,77 @@ describe("A2ANewDmDialog", () => {
screen.queryByRole("button", { name: /start conversation/i }),
).not.toBeInTheDocument();
});
describe("controlled open + initialTarget (DM quick-action deep link)", () => {
it("stays closed by default when open=false, and opens with the target preselected once open=true", () => {
const onOpenChange = vi.fn();
const { rerender } = render(
<A2ANewDmDialog
onCreated={vi.fn()}
open={false}
onOpenChange={onOpenChange}
initialTarget="be-dev-1"
/>,
);
expect(
screen.queryByRole("button", { name: /start conversation/i }),
).not.toBeInTheDocument();
rerender(
<A2ANewDmDialog
onCreated={vi.fn()}
open={true}
onOpenChange={onOpenChange}
initialTarget="be-dev-1"
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("be-dev-1");
});
it("routes trigger clicks and Escape/close through the caller's onOpenChange, not internal state", () => {
const onOpenChange = vi.fn();
render(
<A2ANewDmDialog
onCreated={vi.fn()}
open={true}
onOpenChange={onOpenChange}
initialTarget={null}
/>,
);
// The trigger sits behind Radix's aria-hidden focus-trap boundary
// while the dialog is open, so it must be queried with hidden: true.
fireEvent.click(
screen.getByRole("button", { name: /new dm/i, hidden: true }),
);
// Radix requests a state change; the controlled caller decides — this
// dialog never flips itself open/closed while controlled.
expect(onOpenChange).toHaveBeenCalled();
});
});
});
describe("initialTarget validation (untrusted URL input)", () => {
it("does not preselect an excluded role deep-linked via ?dm=", () => {
render(
<A2ANewDmDialog
open
onOpenChange={() => {}}
initialTarget="auditor"
onCreated={() => {}}
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("");
});
it("does not preselect an unknown slug", () => {
render(
<A2ANewDmDialog
open
onOpenChange={() => {}}
initialTarget="doesnotexist"
onCreated={() => {}}
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("");
});
});
@@ -0,0 +1,542 @@
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 {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
// Extracted from the standalone /a2a page (now the Agents hub's
// Conversations tab, see agents/page.tsx) — pure lift, same suite, new
// import, plus a new describe block for the `?dm=` quick-action handshake.
const {
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2AAdminPairs: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
}));
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Stable objects (like real next/navigation — useRouter()'s return value and
// useSearchParams()'s per-URL value only change reference on an actual
// navigation, not on every render), same idiom as
// workstation/__tests__/page.test.tsx's `searchParams` variable. An unstable
// mock here would make the dm-param effect (deps: [dmParam, router,
// searchParams]) refire every render regardless of the real URL.
const mockRouter = { push: mockPush, replace: mockReplace };
let searchParams = new URLSearchParams("conversation=conv-1");
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
useSearchParams: () => searchParams,
}));
vi.mock("@/hooks/use-agents", () => ({
useAgentDefinitions: () => ({
data: [
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
],
}),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
}));
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
// Select — irrelevant to this suite, stub it out like create-task-dialog's
// suite does for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
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 {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { A2AView } from "../a2a-view";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
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: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 2,
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2AView", () => {
beforeEach(() => {
searchParams = new URLSearchParams("conversation=conv-1");
mockPush.mockClear();
mockReplace.mockClear();
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(withPageRefresh(<A2AView />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("shows the switchboard by default with pair cards grouped into sections", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
it("toggles to the classic conversation list and back", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("Conversations")).toBeInTheDocument();
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2AView />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2AView />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
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(<A2AView />));
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(<A2AView />));
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(<A2AView />));
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("renders the New DM trigger in the header", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.getByRole("button", { name: /new dm/i }),
).toBeInTheDocument();
});
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
// A CEO-initiated DM has no task link and no picker — it must render
// A2ADirectComposer, not the task-gated A2AReplyComposer.
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
agent_a: "ceo",
agent_b: "be-dev-1",
task_id: null,
}),
],
total: 1,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/no linked task, so a reply can't be sent/i),
).not.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(<A2AView />));
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();
});
// DM quick-action deep link: the agent card's DM button lands here as
// `?tab=conversations&dm=<agent slug>`.
describe("`?dm=` quick-action handshake", () => {
it("opens the New DM dialog and strips the dm param, keeping the rest of the query string", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(screen.getByText("New direct message")).toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith("/agents?tab=conversations", {
scroll: false,
});
});
it("does not open the dialog when no dm param is present", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.queryByText("New direct message"),
).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("re-arms for a repeated identical dm value after the strip (latch reset)", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
const { rerender } = render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(1);
// The strip landed: same mounted view, dm gone from the URL.
searchParams = new URLSearchParams("tab=conversations");
rerender(withPageRefresh(<A2AView />));
// The SAME dm value arrives again (re-pasted link / second click
// without a tab remount) — the handshake must fire again.
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
rerender(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(2);
});
it("drops to a bare /agents path when dm was the only param", () => {
searchParams = new URLSearchParams("dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledWith("/agents", { scroll: false });
});
});
});
+38 -2
View File
@@ -21,6 +21,7 @@ import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
import { useAgentDefinitions } from "@/hooks/use-agents";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
@@ -39,6 +40,13 @@ interface A2ANewDmDialogProps {
/** Called with the new (or reopened) conversation's id once the CEO's
* first message is sent — the caller selects/opens it in the page. */
onCreated: (conversationId: string) => void;
/** Controlled-open pair — omit both for the default uncontrolled trigger-
* button behavior (internal state). Pass both to drive the dialog from
* outside, e.g. the agent card's DM quick-action deep link. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Preselects this agent as the target whenever the dialog opens. */
initialTarget?: string | null;
}
/**
@@ -46,8 +54,15 @@ interface A2ANewDmDialogProps {
* classic list only ever show conversations that already exist; this is the
* one surface that creates one, addressed to any agent (never itself).
*/
export function A2ANewDmDialog({ onCreated }: A2ANewDmDialogProps) {
const [open, setOpen] = useState(false);
export function A2ANewDmDialog({
onCreated,
open: openProp,
onOpenChange,
initialTarget,
}: A2ANewDmDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = openProp ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const [targetAgent, setTargetAgent] = useState<string | null>(null);
const [message, setMessage] = useState("");
const create = useCreateCeoConversation();
@@ -57,6 +72,27 @@ export function A2ANewDmDialog({ onCreated }: A2ANewDmDialogProps) {
setMessage("");
};
// A deep-linked initialTarget is untrusted URL input (?dm=<anything>):
// only preselect an agent that exists on the roster AND can receive a DM.
// An excluded role, an unknown slug, or a still-loading roster opens the
// dialog unselected instead — safe over convenient.
const { data: roster = [] } = useAgentDefinitions();
const preselectable =
!!initialTarget &&
roster.some(
(a) =>
a.id === initialTarget &&
(!a.role || !EXCLUDE_NON_DM_ROLES.includes(a.role)),
);
// Preselect on the open transition (render-phase adjustment, not an
// effect — same idiom as the connection-banner reset in a2a-view.tsx).
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open && initialTarget && preselectable) setTargetAgent(initialTarget);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = message.trim();
+627
View File
@@ -0,0 +1,627 @@
"use client";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AAdminPairs,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { usePageRefresh } from "@/hooks";
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 { A2ADirectComposer } from "@/components/a2a/a2a-direct-composer";
import { A2ANewDmDialog } from "@/components/a2a/a2a-new-dm-dialog";
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 { HelpTip } from "@/components/ui/help-tip";
import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { CEO_SLUG, lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Radio,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
type A2AView = "switchboard" | "list";
interface PeekedPair {
agent_a: string;
agent_b: string;
}
/** Conversations tab content — extracted from the standalone /a2a page so it
* can live inside the Agents hub tab shell (see agents/page.tsx). Its own
* `?conversation=` param keeps working on the /agents route; every writer
* below targets /agents (not /a2a) preserving the rest of the query string
* (e.g. `tab=conversations`). */
function A2AViewContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const selectedId = searchParams.get("conversation");
// Desktop default is the switchboard (org-chart pair cards); the classic
// list stays one click away as the mobile/compact fallback.
const [view, setView] = useState<A2AView>("switchboard");
// A pair with no conversation yet, clicked from the switchboard — there is
// nothing to select via `?conversation=`, so it's tracked separately and
// 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,
);
// DM quick-action deep link: the agent card's DM button lands here as
// `?tab=conversations&dm=<agent slug>`. Consumed exactly once — open the
// New DM dialog preselected at that agent, then strip `dm` so a refresh
// doesn't reopen it. The open/target state is set during render (a
// prevDmParam comparison, same idiom as the connection-banner reset below)
// rather than an effect; only the actual URL-stripping side effect below
// needs a real effect.
const dmParam = searchParams.get("dm");
const [dmDialogOpen, setDmDialogOpen] = useState(false);
const [dmTarget, setDmTarget] = useState<string | null>(null);
const [prevDmParam, setPrevDmParam] = useState<string | null>(null);
if (dmParam && dmParam !== prevDmParam) {
setPrevDmParam(dmParam);
setDmTarget(dmParam);
setDmDialogOpen(true);
} else if (!dmParam && prevDmParam !== null) {
// Re-arm once the strip removes `dm` — without this the latch holds the
// last value forever and a repeated identical deep link (re-pasted URL,
// second DM click without a tab remount) silently does nothing.
setPrevDmParam(null);
}
const {
data: conversationData,
isLoading: loadingConversations,
error,
refetch: refetchConversations,
} = useA2AConversations(100);
const {
data: pairsData,
isLoading: loadingPairs,
refetch: refetchPairs,
} = useA2AAdminPairs();
const {
data: messagesData,
isLoading: loadingMessages,
error: messagesError,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchConversations();
},
() => {
void refetchPairs();
},
() => {
void refetchMessages();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchConversations,
refetchPairs,
refetchMessages,
]);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `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,
state: connectionState,
} = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.pairs });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
});
}
}, [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
// fire (prevConnected starts unknown, not false).
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
if (prevConnected.current === false && isConnected) {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
}
prevConnected.current = isConnected;
}, [isConnected, queryClient]);
// Strip `dm` from the URL once it's been picked up above — this effect
// does nothing but sync the URL to an external system (the router), so it
// stays clear of the set-state-in-effect rule; it naturally stops firing
// once the param is gone (dmParam becomes null / the dependency changes).
useEffect(() => {
if (!dmParam) return;
const params = new URLSearchParams(searchParams.toString());
params.delete("dm");
const qs = params.toString();
router.replace(qs ? `/agents?${qs}` : "/agents", { scroll: false });
}, [dmParam, router, searchParams]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
const pulses = useMemo(
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const filteredPairs = useMemo(
() => filterPairs(pairs, filters),
[pairs, filters],
);
const handleSelect = useCallback(
(id: string) => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/agents?${params.toString()}`);
},
[router, searchParams],
);
const handleOpenPair = useCallback(
(pair: AdminPairSummary) => {
if (pair.conversation_id) {
handleSelect(pair.conversation_id);
return;
}
// Never-talked pair: nothing to select, clear any prior selection and
// show the pair's own empty state instead.
setPeekedPair({ agent_a: pair.agent_a, agent_b: pair.agent_b });
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/agents?${qs}` : "/agents");
},
[handleSelect, router, searchParams],
);
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);
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/agents?${qs}` : "/agents");
}, [router, searchParams]);
return (
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
<p className="text-muted-foreground">
Live agent-to-agent conversations watch and chime in
</p>
</div>
<div className="flex items-center gap-4">
<A2ANewDmDialog
onCreated={handleSelect}
open={dmDialogOpen}
onOpenChange={setDmDialogOpen}
initialTarget={dmTarget}
/>
<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). */}
<HelpTip
label={contextOpen ? "Hide context panel" : "Show context panel"}
>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
aria-label={
contextOpen ? "Hide context panel" : "Show context panel"
}
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" />
)}
</Button>
</HelpTip>
</div>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<HelpTip label="Returns to the switchboard/list">
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</HelpTip>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
contextOpen && "xl:col-span-3",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
aria-label="Switchboard: org-chart pair cards"
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
aria-label="Classic conversation list"
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div>
<A2AFilterBar
filters={filters}
onFiltersChange={setFilters}
agentOptions={agentOptions}
view={view}
/>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={filteredPairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={filteredConversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
pulses={pulses}
/>
)}
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<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">
{peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<>
{selected && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<HelpTip label={selected.status === "active" ? "Actively exchanging messages" : "No longer active"}>
<Badge
variant={
selected.status === "active"
? "default"
: "secondary"
}
className="text-xs w-fit"
>
{selected.status}
</Badge>
</HelpTip>
<HelpTip label={new Date(selected.updated_at).toLocaleString()}>
<span className="text-xs text-muted-foreground ml-auto w-fit">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
ago
</span>
</HelpTip>
</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>
{/* Composer: a conversation the CEO itself owns (opened
via "New DM") always gets the direct composer — it's
the CEO's own thread, not something being watched, so
no task link is required. Otherwise this is a watched
agent<->agent conversation: the backend's reply route
rejects with 400 exactly when it has no task link
(replies ride the gateway send path, which requires
one), so a task-less one is read-only — say why instead
of letting the send bounce. Status does NOT gate either
composer: a reply lands in the CEO's own direct thread
with the participant, not in the watched conversation. */}
{selected && (
<div className="shrink-0 border-t -mx-3">
{selected.agent_a === CEO_SLUG ||
selected.agent_b === CEO_SLUG ? (
<A2ADirectComposer
key={selected.id}
conversationId={selected.id}
otherAgent={
selected.agent_a === CEO_SLUG
? selected.agent_b
: selected.agent_a
}
/>
) : selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped
to a task).
</div>
)}
</div>
)}
</>
)}
</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>
</>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export function A2AView() {
return (
<Suspense
fallback={
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-4">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-8" />
</div>
</div>
}
>
<A2AViewContent />
</Suspense>
);
}
@@ -8,6 +8,11 @@ vi.mock("@/hooks/use-agents", () => ({
useStopAgent: () => ({ mutateAsync: vi.fn() }),
}));
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
@@ -171,4 +176,39 @@ describe("AgentCard", () => {
expect(screen.getByText(/12\.3K tok/)).toBeInTheDocument();
expect(screen.getByText(/\$0\.0421/)).toBeInTheDocument();
});
it("offers a DM quick-action that jumps to Conversations pre-targeted at this agent", async () => {
const user = userEvent.setup();
render(<AgentCard agent={AGENT} agentStatus={statusOf()} />);
await user.click(screen.getByRole("button", { name: "DM this agent" }));
expect(mockPush).toHaveBeenCalledWith(
"/agents?tab=conversations&dm=be-dev-1",
);
});
it("hides the DM quick-action for a role that can't read/answer a DM", () => {
const auditor = {
id: "auditor",
name: "Auditor",
role: "auditor",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={auditor} agentStatus={statusOf()} />);
expect(
screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument();
});
it("hides the DM quick-action for the CEO card", () => {
const ceo = {
id: "ceo",
name: "CEO",
role: "ceo",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={ceo} agentStatus={statusOf()} />);
expect(
screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// CEO feedback round 2: Total Agents must reflect the full roster, not the
// orchestrator's live-instance count, and Board + Main PM must fold into one
// "Leadership" band instead of a lone Main PM card wasting a full row.
//
// Extracted from the standalone /agents page (now the Agents hub's Fleet
// tab, see agents/page.tsx) — pure lift, same suite, new import.
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock("@/hooks/use-usage", () => ({
useAgentUsage: () => ({ data: [] }),
}));
const { useOrchestratorStatus, useWaitingAgents, useAgentDefinitions } =
vi.hoisted(() => ({
useOrchestratorStatus: vi.fn(),
useWaitingAgents: vi.fn(),
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
}));
vi.mock("@/components/agents", () => ({
OrchestratorStatusCards: ({ rosterCount }: { rosterCount: number }) => (
<div data-testid="orchestrator-status-cards" data-roster-count={rosterCount} />
),
WaitingAgentsAlert: () => <div data-testid="waiting-agents-alert" />,
AgentGrid: ({
title,
agents,
}: {
title: string;
agents: AgentDefinition[];
}) => (
<div data-testid={"grid-" + title}>
{agents.map((a) => a.id).join(",")}
</div>
),
}));
import { AgentsFleetView } from "../agents-fleet-view";
const AGENTS: AgentDefinition[] = [
{
id: "product-owner",
name: "Product Owner",
role: "product_owner" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "head-marketing",
name: "Head of Marketing",
role: "head_marketing" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "auditor",
name: "Auditor",
role: "auditor" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "main-pm",
name: "Main PM",
role: "main_pm" as AgentDefinition["role"],
team: "main_pm" as AgentDefinition["team"],
},
{
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer" as AgentDefinition["role"],
team: "backend" as AgentDefinition["team"],
},
];
describe("AgentsFleetView", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
useOrchestratorStatus.mockReturnValue({
data: {
total_agents: 2, // deliberately far below the roster size
by_state: { active: 2 },
waiting_count: 0,
agents: [],
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
useWaitingAgents.mockReturnValue({ data: undefined });
});
it("passes the full roster size as the truthful Total Agents count, not the backend's live-instance total", () => {
render(<AgentsFleetView />);
const cards = screen.getByTestId("orchestrator-status-cards");
expect(cards).toHaveAttribute("data-roster-count", "5");
});
it("folds Board and Main PM into one Leadership group instead of separate sections", () => {
render(<AgentsFleetView />);
expect(screen.getByTestId("grid-Leadership")).toHaveTextContent(
"product-owner,head-marketing,auditor,main-pm",
);
expect(screen.queryByTestId("grid-Board")).not.toBeInTheDocument();
expect(screen.queryByTestId("grid-Main PM")).not.toBeInTheDocument();
});
it("still renders the per-cell grids", () => {
render(<AgentsFleetView />);
expect(screen.getByTestId("grid-Backend Cell")).toHaveTextContent(
"be-dev-1",
);
});
it("does not render the Support grid when no support agents match", () => {
render(<AgentsFleetView />);
expect(screen.queryByTestId("grid-Support")).not.toBeInTheDocument();
});
});
+68 -44
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useStopAgent } from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
@@ -20,11 +21,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Activity, Square } from "lucide-react";
import { MoreHorizontal, Activity, MessageSquare, Square } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { agentStateDescription, stateColors } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
import type { AgentUsageRow } from "@/types";
interface AgentCardProps {
@@ -34,8 +36,12 @@ interface AgentCardProps {
}
export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
const router = useRouter();
const stopAgent = useStopAgent();
const state = agentStatus?.state || "stopped";
// Same exclusion list the New DM dialog enforces (self, plus roles that
// can't read/answer a DM) — a card for one of those renders no DM button.
const canDm = !!agent.role && !EXCLUDE_NON_DM_ROLES.includes(agent.role);
// "Up" = anything that isn't a terminal/down state. Spawn is offered ONLY when
// the agent is down; an up agent (active / running / idle / paused / …) shows
// View Details + Stop instead. We list the DOWN states rather than the up ones
@@ -78,54 +84,72 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
<CardTitle className="truncate text-base">
{agent.name || "Unknown Agent"}
</CardTitle>
<DropdownMenu>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
<div className="flex shrink-0 items-center gap-0.5">
{canDm && (
<HelpTip label="DM this agent">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
title="Agent actions"
className="h-6 w-6"
aria-label="DM this agent"
title="DM this agent"
onClick={() =>
router.push(`/agents?tab=conversations&dm=${agent.id}`)
}
>
<MoreHorizontal className="h-3.5 w-3.5" />
<MessageSquare className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
)}
{isActive && (
<>
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
<DropdownMenuItem asChild>
<Link href={"/agents/" + agent.id} prefetch={false}>
<Activity className="h-4 w-4 mr-2" />
View Details
</Link>
</DropdownMenuItem>
</HelpTip>
<DropdownMenuSeparator />
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
<DropdownMenuItem onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop Gracefully
</DropdownMenuItem>
</HelpTip>
<HelpTip label="Kills the container immediately, even mid-task" side="left">
<DropdownMenuItem
onClick={() => handleStop(false)}
className="text-red-600"
>
<Square className="h-4 w-4 mr-2" />
Force Stop
</DropdownMenuItem>
</HelpTip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</HelpTip>
)}
<DropdownMenu>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
title="Agent actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
)}
{isActive && (
<>
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
<DropdownMenuItem asChild>
<Link href={"/agents/" + agent.id} prefetch={false}>
<Activity className="h-4 w-4 mr-2" />
View Details
</Link>
</DropdownMenuItem>
</HelpTip>
<DropdownMenuSeparator />
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
<DropdownMenuItem onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop Gracefully
</DropdownMenuItem>
</HelpTip>
<HelpTip label="Kills the container immediately, even mid-task" side="left">
<DropdownMenuItem
onClick={() => handleStop(false)}
className="text-red-600"
>
<Square className="h-4 w-4 mr-2" />
Force Stop
</DropdownMenuItem>
</HelpTip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="truncate text-xs">
{agent.role?.replace(/_/g, " ") || "N/A"}
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useMemo } from "react";
import {
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
import { useAgentUsage } from "@/hooks/use-usage";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
getSupportAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
WaitingAgentsAlert,
AgentGrid,
} from "@/components/agents";
/** Fleet tab content — extracted from the standalone /agents page so it can
* live inside the Agents hub tab shell (see agents/page.tsx). */
export function AgentsFleetView() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
const { data: usageRows } = useAgentUsage();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Convert agents array to a record keyed by agent_id for easy lookup
const agentStatuses = useMemo(() => {
const result: Record<string, AgentStatusResponse> = {};
if (status?.agents) {
for (const agent of status.agents) {
result[agent.agent_id] = agent;
}
}
return result;
}, [status]);
// Convert usage rows to a record keyed by agent_slug
const agentUsageMap = useMemo(() => {
const result: Record<string, AgentUsageRow> = {};
for (const row of usageRows ?? []) {
result[row.agent_slug] = row;
}
return result;
}, [usageRows]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
<p className="text-muted-foreground">
Monitor and control your AI workforce
</p>
</div>
</div>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Status Overview — Total Agents is the full roster size, not the
orchestrator's live-instance count, so it stays truthful even
when most of the roster isn't currently spawned. */}
<OrchestratorStatusCards
status={status}
isLoading={isLoading}
rosterCount={agents.length}
rosterLoading={agentsLoading}
/>
{/* Waiting Agents Alert */}
{waitingAgents && (
<WaitingAgentsAlert waitingAgents={waitingAgents} />
)}
</>
)}
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
one Leadership band so a lone Main PM card never wastes a full row. */}
<AgentGrid
title="Leadership"
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Backend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Frontend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="UX/UI Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
and the root PR Reviewer — only rendered when at least one matches */}
{getSupportAgents(agents).length > 0 && (
<AgentGrid
title="Support"
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
agents={getSupportAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
)}
</div>
);
}
+1 -8
View File
@@ -20,7 +20,6 @@ import {
Cpu,
Sparkles,
Building2,
Radio,
Share2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -86,17 +85,11 @@ export const navItems = [
icon: Database,
tip: "Search the RAG corpus — playbooks, learnings, and vault notes",
},
{
title: "A2A",
href: "/a2a",
icon: Radio,
tip: "Live agent-to-agent message switchboard and history",
},
{
title: "Agents",
href: "/agents",
icon: Bot,
tip: "Every agent's live state, spawn controls, and activity stream",
tip: "Every agent's live state, spawn controls, and A2A conversations",
},
{
title: "Journals",