mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
+25
-15
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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't
|
||||
A2A'd each other yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
|
||||
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{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'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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,159 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
useOrchestratorStatus,
|
||||
useWaitingAgents,
|
||||
useAgentDefinitions,
|
||||
} from "@/hooks/use-agents";
|
||||
import { useAgentUsage } from "@/hooks/use-usage";
|
||||
import { AgentStatusResponse, AgentUsageRow } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import {
|
||||
getBoardAgents,
|
||||
getMainPm,
|
||||
getBackendAgents,
|
||||
getFrontendAgents,
|
||||
getUxAgents,
|
||||
getSupportAgents,
|
||||
} from "@/lib/agent-definitions";
|
||||
import {
|
||||
OrchestratorStatusCards,
|
||||
WaitingAgentsAlert,
|
||||
AgentGrid,
|
||||
} from "@/components/agents";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AgentsFleetView } from "@/components/agents/agents-fleet-view";
|
||||
import { A2AView } from "@/components/a2a/a2a-view";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
|
||||
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
|
||||
const { data: waitingAgents } = useWaitingAgents();
|
||||
const { data: usageRows } = useAgentUsage();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Valid tab values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { register, unregister, refresh } = usePageRefresh();
|
||||
interface TabDef {
|
||||
value: "fleet" | "conversations";
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
void refetch();
|
||||
};
|
||||
register(cb);
|
||||
return () => unregister(cb);
|
||||
}, [register, unregister, refetch]);
|
||||
const TAB_DEFS: TabDef[] = [
|
||||
{
|
||||
value: "fleet",
|
||||
label: "Fleet",
|
||||
hint: "Every agent's live state, spawn controls, and activity stream",
|
||||
},
|
||||
{
|
||||
value: "conversations",
|
||||
label: "Conversations",
|
||||
hint: "Live agent-to-agent message switchboard and history",
|
||||
},
|
||||
];
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
// Convert agents array to a record keyed by agent_id for easy lookup
|
||||
const agentStatuses = useMemo(() => {
|
||||
const result: Record<string, AgentStatusResponse> = {};
|
||||
if (status?.agents) {
|
||||
for (const agent of status.agents) {
|
||||
result[agent.agent_id] = agent;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [status]);
|
||||
function isValidTab(value: string | null): value is TabValue {
|
||||
return TAB_VALUES.includes(value as TabValue);
|
||||
}
|
||||
|
||||
// Convert usage rows to a record keyed by agent_slug
|
||||
const agentUsageMap = useMemo(() => {
|
||||
const result: Record<string, AgentUsageRow> = {};
|
||||
for (const row of usageRows ?? []) {
|
||||
result[row.agent_slug] = row;
|
||||
}
|
||||
return result;
|
||||
}, [usageRows]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner component that reads URL params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const rawTab = searchParams.get("tab");
|
||||
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "fleet";
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`/agents?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor and control your AI workforce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
{TAB_DEFS.map((tab) => (
|
||||
<Tooltip key={tab.value}>
|
||||
<TooltipTrigger asChild>
|
||||
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
|
||||
own data-state; re-assert the real selection state
|
||||
explicitly (see task-detail/task-tabs.tsx) so the
|
||||
data-[state=active] styling still fires. */}
|
||||
<TabsTrigger
|
||||
value={tab.value}
|
||||
data-state={tab.value === activeTab ? "active" : "inactive"}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tab.hint}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Orchestrator Not Running"
|
||||
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Status Overview — Total Agents is the full roster size, not the
|
||||
orchestrator's live-instance count, so it stays truthful even
|
||||
when most of the roster isn't currently spawned. */}
|
||||
<OrchestratorStatusCards
|
||||
status={status}
|
||||
isLoading={isLoading}
|
||||
rosterCount={agents.length}
|
||||
rosterLoading={agentsLoading}
|
||||
/>
|
||||
<TabsContent value="fleet" className="mt-4">
|
||||
<AgentsFleetView />
|
||||
</TabsContent>
|
||||
|
||||
{/* Waiting Agents Alert */}
|
||||
{waitingAgents && (
|
||||
<WaitingAgentsAlert waitingAgents={waitingAgents} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<TabsContent value="conversations" className="mt-4">
|
||||
<A2AView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
|
||||
one Leadership band so a lone Main PM card never wastes a full row. */}
|
||||
<AgentGrid
|
||||
title="Leadership"
|
||||
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
|
||||
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page export — wraps in Suspense for useSearchParams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
<AgentGrid
|
||||
title="Backend Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getBackendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
<AgentGrid
|
||||
title="Frontend Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getFrontendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
<AgentGrid
|
||||
title="UX/UI Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getUxAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
|
||||
and the root PR Reviewer — only rendered when at least one matches */}
|
||||
{getSupportAgents(agents).length > 0 && (
|
||||
<AgentGrid
|
||||
title="Support"
|
||||
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
|
||||
agents={getSupportAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-72" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AgentsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ import React from "react";
|
||||
|
||||
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
|
||||
|
||||
vi.mock("@/hooks/use-agents", () => ({
|
||||
useAgentDefinitions: () => ({
|
||||
data: [
|
||||
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
|
||||
{ id: "auditor", name: "Auditor", role: "auditor", team: "board" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
useCreateCeoConversation: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
@@ -100,4 +109,77 @@ describe("A2ANewDmDialog", () => {
|
||||
screen.queryByRole("button", { name: /start conversation/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("controlled open + initialTarget (DM quick-action deep link)", () => {
|
||||
it("stays closed by default when open=false, and opens with the target preselected once open=true", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<A2ANewDmDialog
|
||||
onCreated={vi.fn()}
|
||||
open={false}
|
||||
onOpenChange={onOpenChange}
|
||||
initialTarget="be-dev-1"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /start conversation/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<A2ANewDmDialog
|
||||
onCreated={vi.fn()}
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
initialTarget="be-dev-1"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Agent")).toHaveValue("be-dev-1");
|
||||
});
|
||||
|
||||
it("routes trigger clicks and Escape/close through the caller's onOpenChange, not internal state", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<A2ANewDmDialog
|
||||
onCreated={vi.fn()}
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
initialTarget={null}
|
||||
/>,
|
||||
);
|
||||
// The trigger sits behind Radix's aria-hidden focus-trap boundary
|
||||
// while the dialog is open, so it must be queried with hidden: true.
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /new dm/i, hidden: true }),
|
||||
);
|
||||
// Radix requests a state change; the controlled caller decides — this
|
||||
// dialog never flips itself open/closed while controlled.
|
||||
expect(onOpenChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialTarget validation (untrusted URL input)", () => {
|
||||
it("does not preselect an excluded role deep-linked via ?dm=", () => {
|
||||
render(
|
||||
<A2ANewDmDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
initialTarget="auditor"
|
||||
onCreated={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Agent")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not preselect an unknown slug", () => {
|
||||
render(
|
||||
<A2ANewDmDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
initialTarget="doesnotexist"
|
||||
onCreated={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Agent")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { PageRefreshProvider } from "@/components/providers";
|
||||
import type {
|
||||
AdminConversationSummary,
|
||||
AdminPairSummary,
|
||||
A2AChatMessage,
|
||||
} from "@/lib/api/a2a";
|
||||
|
||||
// Extracted from the standalone /a2a page (now the Agents hub's
|
||||
// Conversations tab, see agents/page.tsx) — pure lift, same suite, new
|
||||
// import, plus a new describe block for the `?dm=` quick-action handshake.
|
||||
|
||||
const {
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useA2AAdminPairs,
|
||||
useA2ALiveStream,
|
||||
invalidateQueries,
|
||||
a2aLiveKeys,
|
||||
} = vi.hoisted(() => ({
|
||||
useA2AConversations: vi.fn(),
|
||||
useA2AMessages: vi.fn(),
|
||||
useA2AAdminPairs: vi.fn(),
|
||||
useA2ALiveStream: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
a2aLiveKeys: {
|
||||
all: ["a2a-live"] as const,
|
||||
conversations: ["a2a-live", "conversations"] as const,
|
||||
pairs: ["a2a-live", "pairs"] as const,
|
||||
messages: (conversationId: string) =>
|
||||
["a2a-live", "messages", conversationId] as const,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
// Stable objects (like real next/navigation — useRouter()'s return value and
|
||||
// useSearchParams()'s per-URL value only change reference on an actual
|
||||
// navigation, not on every render), same idiom as
|
||||
// workstation/__tests__/page.test.tsx's `searchParams` variable. An unstable
|
||||
// mock here would make the dm-param effect (deps: [dmParam, router,
|
||||
// searchParams]) refire every render regardless of the real URL.
|
||||
const mockRouter = { push: mockPush, replace: mockReplace };
|
||||
let searchParams = new URLSearchParams("conversation=conv-1");
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useSearchParams: () => searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-agents", () => ({
|
||||
useAgentDefinitions: () => ({
|
||||
data: [
|
||||
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
a2aLiveKeys,
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useA2AAdminPairs,
|
||||
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
|
||||
// Select — irrelevant to this suite, stub it out like create-task-dialog's
|
||||
// suite does for the same component.
|
||||
vi.mock("@/components/agents/agent-selector", () => ({
|
||||
AgentSelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
useA2ALiveStream,
|
||||
}));
|
||||
|
||||
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
|
||||
// so this suite doesn't need a real QueryClientProvider.
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTask: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: vi.fn(() => ({ invalidateQueries })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { A2AView } from "../a2a-view";
|
||||
|
||||
function withPageRefresh(ui: ReactNode) {
|
||||
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
|
||||
}
|
||||
|
||||
function buildConversation(
|
||||
overrides: Partial<AdminConversationSummary> = {},
|
||||
): AdminConversationSummary {
|
||||
return {
|
||||
id: "conv-1",
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-qa",
|
||||
topic: "QA handoff",
|
||||
task_id: "11111111-2222-3333-4444-555555555555",
|
||||
status: "active",
|
||||
message_count: 2,
|
||||
last_message_at: "2026-07-02T09:00:00Z",
|
||||
last_message_preview: "preview",
|
||||
created_at: "2026-07-01T08:00:00Z",
|
||||
updated_at: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPair(
|
||||
overrides: Partial<AdminPairSummary> = {},
|
||||
): AdminPairSummary {
|
||||
return {
|
||||
agent_a: "be-dev-1",
|
||||
role_a: "developer",
|
||||
team_a: "backend",
|
||||
agent_b: "be-qa",
|
||||
role_b: "qa",
|
||||
team_b: "backend",
|
||||
group_key: "cell-backend",
|
||||
conversation_id: "conv-1",
|
||||
last_message_at: "2026-07-02T09:00:00Z",
|
||||
message_count: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessage(): A2AChatMessage {
|
||||
return {
|
||||
id: "m1",
|
||||
conversation_id: "conv-1",
|
||||
from_agent: "be-qa",
|
||||
content: "transcript body text",
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: "2026-07-02T09:00:00Z",
|
||||
edited_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2AView", () => {
|
||||
beforeEach(() => {
|
||||
searchParams = new URLSearchParams("conversation=conv-1");
|
||||
mockPush.mockClear();
|
||||
mockReplace.mockClear();
|
||||
invalidateQueries.mockReset();
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: { items: [buildConversation()], total: 1 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useA2AMessages.mockReturnValue({
|
||||
data: { items: [buildMessage()], total: 1, has_more: false },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the transcript pane and composer for a task-linked conversation", () => {
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByText("transcript body text")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/posts into this conversation — visible to both participants/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the composer for a closed but task-linked conversation", () => {
|
||||
// The watched conversation's status must NOT gate the composer — the reply
|
||||
// lands in the CEO's own direct thread with the participant.
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: { items: [buildConversation({ status: "closed" })], total: 1 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the composer and explains why for a task-less conversation", () => {
|
||||
// task_id === null is the authoritative signal that the backend's reply
|
||||
// route would 400 (replies require a task link), so the pane is read-only.
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: { items: [buildConversation({ task_id: null })], total: 1 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/no linked task, so a reply can't be sent/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: {
|
||||
type: "a2a.message",
|
||||
conversation_id: "conv-1",
|
||||
message_id: "m9",
|
||||
from_agent: "be-dev-1",
|
||||
to_agent: "be-qa",
|
||||
body_excerpt: "capped",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.conversations,
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.pairs,
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.messages("conv-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: {
|
||||
type: "a2a.message",
|
||||
conversation_id: "conv-other",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
a2aMessages: [],
|
||||
isConnected: false,
|
||||
state: "disconnected",
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.conversations,
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.pairs,
|
||||
});
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.messages("conv-1"),
|
||||
});
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the switchboard by default with pair cards grouped into sections", () => {
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [buildPair()], total: 1 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByText("Switchboard")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles to the classic conversation list and back", () => {
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [buildPair()], total: 1 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByText("Switchboard")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Classic conversation list"));
|
||||
expect(screen.getByText("Conversations")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
|
||||
expect(screen.getByText("Switchboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
|
||||
// stale (events missed during the disconnect); invalidate the a2a query
|
||||
// family so react-query refetches.
|
||||
it("invalidates a2a queries on a false → true reconnect transition", () => {
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: false,
|
||||
state: "disconnected",
|
||||
});
|
||||
const { rerender } = render(withPageRefresh(<A2AView />));
|
||||
// No invalidation while offline.
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.all,
|
||||
});
|
||||
|
||||
invalidateQueries.mockReset();
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
act(() => {
|
||||
rerender(withPageRefresh(<A2AView />));
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.all,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invalidate a2a queries on initial mount when already connected", () => {
|
||||
// prevConnected starts unknown; a mount with isConnected=true must NOT
|
||||
// fire a reconnect invalidation (only a real false → true transition does).
|
||||
invalidateQueries.mockReset();
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
state: "connected",
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.all,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the filter trigger above the switchboard/list content", () => {
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [buildPair()], total: 1 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /^Filters$/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the switchboard's pairs by a selected agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildPair(),
|
||||
buildPair({
|
||||
agent_a: "auditor",
|
||||
agent_b: "product-owner",
|
||||
group_key: "board",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
|
||||
|
||||
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the classic list's conversations by a selected agent", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation(),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
agent_a: "ux-dev-1",
|
||||
agent_b: "ux-qa",
|
||||
topic: "Design review",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
fireEvent.click(screen.getByTitle("Classic conversation list"));
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
|
||||
|
||||
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the New DM trigger in the header", () => {
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /new dm/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
|
||||
// A CEO-initiated DM has no task link and no picker — it must render
|
||||
// A2ADirectComposer, not the task-gated A2AReplyComposer.
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation({
|
||||
agent_a: "ceo",
|
||||
agent_b: "be-dev-1",
|
||||
task_id: null,
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/no linked task, so a reply can't be sent/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the classic list's conversations by task id fragment", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation({
|
||||
task_id: "11111111-2222-3333-4444-555555555555",
|
||||
}),
|
||||
buildConversation({
|
||||
id: "conv-2",
|
||||
topic: "Design review",
|
||||
task_id: "99999999-8888-7777-6666-555555555555",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2AView />));
|
||||
fireEvent.click(screen.getByTitle("Classic conversation list"));
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
|
||||
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
|
||||
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// DM quick-action deep link: the agent card's DM button lands here as
|
||||
// `?tab=conversations&dm=<agent slug>`.
|
||||
describe("`?dm=` quick-action handshake", () => {
|
||||
it("opens the New DM dialog and strips the dm param, keeping the rest of the query string", () => {
|
||||
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
|
||||
render(withPageRefresh(<A2AView />));
|
||||
|
||||
expect(screen.getByText("New direct message")).toBeInTheDocument();
|
||||
expect(mockReplace).toHaveBeenCalledTimes(1);
|
||||
expect(mockReplace).toHaveBeenCalledWith("/agents?tab=conversations", {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open the dialog when no dm param is present", () => {
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(
|
||||
screen.queryByText("New direct message"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-arms for a repeated identical dm value after the strip (latch reset)", () => {
|
||||
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
|
||||
const { rerender } = render(withPageRefresh(<A2AView />));
|
||||
expect(mockReplace).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The strip landed: same mounted view, dm gone from the URL.
|
||||
searchParams = new URLSearchParams("tab=conversations");
|
||||
rerender(withPageRefresh(<A2AView />));
|
||||
|
||||
// The SAME dm value arrives again (re-pasted link / second click
|
||||
// without a tab remount) — the handshake must fire again.
|
||||
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
|
||||
rerender(withPageRefresh(<A2AView />));
|
||||
expect(mockReplace).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops to a bare /agents path when dm was the only param", () => {
|
||||
searchParams = new URLSearchParams("dm=be-dev-1");
|
||||
render(withPageRefresh(<A2AView />));
|
||||
expect(mockReplace).toHaveBeenCalledWith("/agents", { scroll: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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't
|
||||
A2A'd each other yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
|
||||
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{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'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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useStopAgent } from "@/hooks/use-agents";
|
||||
import { AgentStatusResponse } from "@/types";
|
||||
import { AgentDefinition } from "@/lib/agent-definitions";
|
||||
@@ -20,11 +21,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Activity, Square } from "lucide-react";
|
||||
import { MoreHorizontal, Activity, MessageSquare, Square } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { agentStateDescription, stateColors } from "./agent-state-badge";
|
||||
import { SpawnAgentDialog } from "./spawn-agent-dialog";
|
||||
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
|
||||
import type { AgentUsageRow } from "@/types";
|
||||
|
||||
interface AgentCardProps {
|
||||
@@ -34,8 +36,12 @@ interface AgentCardProps {
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
const router = useRouter();
|
||||
const stopAgent = useStopAgent();
|
||||
const state = agentStatus?.state || "stopped";
|
||||
// Same exclusion list the New DM dialog enforces (self, plus roles that
|
||||
// can't read/answer a DM) — a card for one of those renders no DM button.
|
||||
const canDm = !!agent.role && !EXCLUDE_NON_DM_ROLES.includes(agent.role);
|
||||
// "Up" = anything that isn't a terminal/down state. Spawn is offered ONLY when
|
||||
// the agent is down; an up agent (active / running / idle / paused / …) shows
|
||||
// View Details + Stop instead. We list the DOWN states rather than the up ones
|
||||
@@ -78,54 +84,72 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name || "Unknown Agent"}
|
||||
</CardTitle>
|
||||
<DropdownMenu>
|
||||
<HelpTip label="Agent actions">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{canDm && (
|
||||
<HelpTip label="DM this agent">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label="Agent actions"
|
||||
title="Agent actions"
|
||||
className="h-6 w-6"
|
||||
aria-label="DM this agent"
|
||||
title="DM this agent"
|
||||
onClick={() =>
|
||||
router.push(`/agents?tab=conversations&dm=${agent.id}`)
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</HelpTip>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && (
|
||||
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={"/agents/" + agent.id} prefetch={false}>
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
<DropdownMenuSeparator />
|
||||
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
|
||||
<DropdownMenuItem onClick={() => handleStop(true)}>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop Gracefully
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
<HelpTip label="Kills the container immediately, even mid-task" side="left">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStop(false)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Force Stop
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HelpTip>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<HelpTip label="Agent actions">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label="Agent actions"
|
||||
title="Agent actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</HelpTip>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && (
|
||||
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={"/agents/" + agent.id} prefetch={false}>
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
<DropdownMenuSeparator />
|
||||
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
|
||||
<DropdownMenuItem onClick={() => handleStop(true)}>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Stop Gracefully
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
<HelpTip label="Kills the container immediately, even mid-task" side="left">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStop(false)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2" />
|
||||
Force Stop
|
||||
</DropdownMenuItem>
|
||||
</HelpTip>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="truncate text-xs">
|
||||
{agent.role?.replace(/_/g, " ") || "N/A"}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
useOrchestratorStatus,
|
||||
useWaitingAgents,
|
||||
useAgentDefinitions,
|
||||
} from "@/hooks/use-agents";
|
||||
import { useAgentUsage } from "@/hooks/use-usage";
|
||||
import { AgentStatusResponse, AgentUsageRow } from "@/types";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import {
|
||||
getBoardAgents,
|
||||
getMainPm,
|
||||
getBackendAgents,
|
||||
getFrontendAgents,
|
||||
getUxAgents,
|
||||
getSupportAgents,
|
||||
} from "@/lib/agent-definitions";
|
||||
import {
|
||||
OrchestratorStatusCards,
|
||||
WaitingAgentsAlert,
|
||||
AgentGrid,
|
||||
} from "@/components/agents";
|
||||
|
||||
/** Fleet tab content — extracted from the standalone /agents page so it can
|
||||
* live inside the Agents hub tab shell (see agents/page.tsx). */
|
||||
export function AgentsFleetView() {
|
||||
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
|
||||
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
|
||||
const { data: waitingAgents } = useWaitingAgents();
|
||||
const { data: usageRows } = useAgentUsage();
|
||||
|
||||
const { register, unregister, refresh } = usePageRefresh();
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
void refetch();
|
||||
};
|
||||
register(cb);
|
||||
return () => unregister(cb);
|
||||
}, [register, unregister, refetch]);
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
error.message?.includes("ECONNREFUSED") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
// Convert agents array to a record keyed by agent_id for easy lookup
|
||||
const agentStatuses = useMemo(() => {
|
||||
const result: Record<string, AgentStatusResponse> = {};
|
||||
if (status?.agents) {
|
||||
for (const agent of status.agents) {
|
||||
result[agent.agent_id] = agent;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [status]);
|
||||
|
||||
// Convert usage rows to a record keyed by agent_slug
|
||||
const agentUsageMap = useMemo(() => {
|
||||
const result: Record<string, AgentUsageRow> = {};
|
||||
for (const row of usageRows ?? []) {
|
||||
result[row.agent_slug] = row;
|
||||
}
|
||||
return result;
|
||||
}, [usageRows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor and control your AI workforce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Orchestrator Not Running"
|
||||
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Status Overview — Total Agents is the full roster size, not the
|
||||
orchestrator's live-instance count, so it stays truthful even
|
||||
when most of the roster isn't currently spawned. */}
|
||||
<OrchestratorStatusCards
|
||||
status={status}
|
||||
isLoading={isLoading}
|
||||
rosterCount={agents.length}
|
||||
rosterLoading={agentsLoading}
|
||||
/>
|
||||
|
||||
{/* Waiting Agents Alert */}
|
||||
{waitingAgents && (
|
||||
<WaitingAgentsAlert waitingAgents={waitingAgents} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
|
||||
one Leadership band so a lone Main PM card never wastes a full row. */}
|
||||
<AgentGrid
|
||||
title="Leadership"
|
||||
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
|
||||
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
<AgentGrid
|
||||
title="Backend Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getBackendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
<AgentGrid
|
||||
title="Frontend Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getFrontendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
<AgentGrid
|
||||
title="UX/UI Cell"
|
||||
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
|
||||
agents={getUxAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
|
||||
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
|
||||
and the root PR Reviewer — only rendered when at least one matches */}
|
||||
{getSupportAgents(agents).length > 0 && (
|
||||
<AgentGrid
|
||||
title="Support"
|
||||
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
|
||||
agents={getSupportAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user