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();
});
});
+96 -143
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();
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",
},
];
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(value: string | null): value is TabValue {
return TAB_VALUES.includes(value as TabValue);
}
// ---------------------------------------------------------------------------
// 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()}`);
};
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>
<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}
/>
)}
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();
});
});
+25 -1
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,6 +84,23 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
<CardTitle className="truncate text-base">
{agent.name || "Unknown Agent"}
</CardTitle>
<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"
aria-label="DM this agent"
title="DM this agent"
onClick={() =>
router.push(`/agents?tab=conversations&dm=${agent.id}`)
}
>
<MessageSquare className="h-3.5 w-3.5" />
</Button>
</HelpTip>
)}
<DropdownMenu>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
@@ -127,6 +150,7 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="truncate text-xs">
{agent.role?.replace(/_/g, " ") || "N/A"}
{agent.team && " • " + agent.team.replace(/_/g, " ")}
@@ -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",