mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)
* feat(tasks): Secretary full task access; PM lighter editing — and a closed over-permission hole Secretary: the CEO-gated edit directive covers the full content surface (title/description/AC/priority/team/complexity/nature + claim-aware reassignment through the real reassign paths, enum coercion, slug or UUID assignees), and read_task returns full detail (notes, plan, bounded progress, PR refs). The submit_directive tool docs never mentioned edit at all — fixed, it was undiscoverable. PMs: scouted the PATCH route and found has_higher_perms gave PM identities UNRESTRICTED admin (ASSIGN is not team-scoped) — wider than 'not that much'. Now: cell PMs hard-403 outside their team, and both PM roles are capped to the content allowlist (title/description/AC/ priority) with zero status changes via this surface. CEO/Board/Auditor keep full admin. Built subagent-driven (Sonnet 5), reviewed. * feat(a2a): the switchboard — org-chart pair cards with live activity 70 permission-matrix-derived pair cards (cells/pm-chain/board/cross), lighting on either direction's a2a.message frames with a 45s fade — A2A only, never verbs, per CEO ruling. Click-through reuses the v1 transcript + chime-in drawer; v1 list stays as the mobile fallback. One CEO-gated /a2a/chat/admin/pairs route joins the static matrix against conversations in a single bulk query. Built subagent-driven (Sonnet 5), reviewed; pre-existing agent-utils slug-map gap flagged. * fix(runtime): conventions ambient covers the MegaTask project_ids scope _resolve_intake_ambient forwarded project_ids only to the history-digest resolver — a MegaTask intake got no architectural-conventions block even with the flag on. The conventions resolver now takes project_ids first (mirroring the history resolver), both share one order-preserving _projects_by_ids helper, and a regression test pins the threading to both sub-resolvers. Built subagent-driven (Sonnet 5), reviewed. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,21 +1,28 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { AdminConversationSummary, A2AChatMessage } from "@/lib/api/a2a";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type {
|
||||
AdminConversationSummary,
|
||||
AdminPairSummary,
|
||||
A2AChatMessage,
|
||||
} from "@/lib/api/a2a";
|
||||
|
||||
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,
|
||||
},
|
||||
@@ -30,6 +37,7 @@ vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
a2aLiveKeys,
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useA2AAdminPairs,
|
||||
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
@@ -74,6 +82,22 @@ function buildConversation(
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -103,8 +127,14 @@ describe("A2APage", () => {
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useA2AAdminPairs.mockReturnValue({
|
||||
data: { items: [], total: 0 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
});
|
||||
});
|
||||
@@ -150,7 +180,7 @@ describe("A2APage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invalidates conversations + selected messages on a matching a2a.message frame", () => {
|
||||
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: {
|
||||
type: "a2a.message",
|
||||
@@ -161,33 +191,69 @@ describe("A2APage", () => {
|
||||
body_excerpt: "capped",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
a2aMessages: [],
|
||||
isConnected: true,
|
||||
});
|
||||
render(<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 list for frames of other conversations", () => {
|
||||
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,
|
||||
});
|
||||
render(<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(<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(<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, 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 { 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 { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-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";
|
||||
@@ -20,9 +24,22 @@ import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { lastSenderOf } from "@/components/a2a/a2a-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessagesSquare, Radio, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
MessagesSquare,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
type A2AView = "switchboard" | "list";
|
||||
|
||||
interface PeekedPair {
|
||||
agent_a: string;
|
||||
agent_b: string;
|
||||
}
|
||||
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
@@ -47,12 +64,25 @@ function A2APageContent() {
|
||||
|
||||
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);
|
||||
|
||||
const {
|
||||
data: conversationData,
|
||||
isLoading: loadingConversations,
|
||||
error,
|
||||
refetch: refetchConversations,
|
||||
} = useA2AConversations();
|
||||
} = useA2AConversations(100);
|
||||
const {
|
||||
data: pairsData,
|
||||
isLoading: loadingPairs,
|
||||
refetch: refetchPairs,
|
||||
} = useA2AAdminPairs();
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
@@ -63,10 +93,11 @@ function A2APageContent() {
|
||||
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
|
||||
// frame's excerpt is capped by design, so REST stays the source of truth and
|
||||
// react-query refetches the affected queries.
|
||||
const { lastMessage, isConnected } = useA2ALiveStream();
|
||||
const { lastMessage, a2aMessages, isConnected } = 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),
|
||||
@@ -74,8 +105,17 @@ function A2APageContent() {
|
||||
}
|
||||
}, [lastMessage, queryClient, selectedId]);
|
||||
|
||||
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 handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
setPeekedPair(null);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("conversation", id);
|
||||
router.push(`/a2a?${params.toString()}`);
|
||||
@@ -83,8 +123,26 @@ function A2APageContent() {
|
||||
[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 handleRefresh = () => {
|
||||
refetchConversations();
|
||||
refetchPairs();
|
||||
if (selectedId) refetchMessages();
|
||||
};
|
||||
|
||||
@@ -137,20 +195,56 @@ function A2APageContent() {
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
|
||||
{/* Panel 1: Conversations */}
|
||||
{/* Panel 1: Switchboard (default) / classic conversation list */}
|
||||
<Card className="col-span-12 lg:col-span-4 flex flex-col overflow-hidden">
|
||||
<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">Conversations</span>
|
||||
<span className="text-sm font-medium">
|
||||
{view === "switchboard" ? "Switchboard" : "Conversations"}
|
||||
</span>
|
||||
<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"}
|
||||
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"}
|
||||
onClick={() => setView("list")}
|
||||
title="Classic conversation list"
|
||||
>
|
||||
<ListIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<A2AConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
isLoading={loadingConversations}
|
||||
/>
|
||||
{view === "switchboard" ? (
|
||||
<A2ASwitchboard
|
||||
pairs={pairs}
|
||||
pulses={pulses}
|
||||
selectedConversationId={selectedId}
|
||||
isLoading={loadingPairs}
|
||||
onOpenPair={handleOpenPair}
|
||||
/>
|
||||
) : (
|
||||
<A2AConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
isLoading={loadingConversations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -211,6 +305,17 @@ function A2APageContent() {
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : peekedPair ? (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4 max-w-xs">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
|
||||
{getAgentDisplayName(peekedPair.agent_b)} haven't
|
||||
A2A'd each other yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessagesSquare}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { A2APairCard } from "../a2a-pair-card";
|
||||
|
||||
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: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2APairCard", () => {
|
||||
it("renders both display names, message count, and relative time", () => {
|
||||
render(
|
||||
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onOpen when clicked", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={onOpen} />);
|
||||
fireEvent.click(screen.getByTestId("pair-card"));
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders dimmed and shows 'No A2A yet' for a never-talked pair", () => {
|
||||
render(
|
||||
<A2APairCard
|
||||
pair={buildPair({ conversation_id: null, last_message_at: null })}
|
||||
pulsedAt={null}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No A2A yet")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pair-card")).toHaveClass("opacity-60");
|
||||
// No message-count badge for a pair with no history.
|
||||
expect(screen.queryByText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the card as selected via aria-pressed", () => {
|
||||
render(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={null}
|
||||
isSelected
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A2APairCard pulsing (frame -> matching card lights up, then fades)", () => {
|
||||
// Capture the rAF callback instead of letting it fire on the real event
|
||||
// loop, so the activation/fade transition is fully deterministic.
|
||||
let rafCallback: FrameRequestCallback | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallback = null;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
rafCallback = cb;
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts cold with no pulse", () => {
|
||||
render(<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("goes hot the instant a matching pulsedAt is received", () => {
|
||||
const { rerender } = render(
|
||||
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000000000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("fades back to cold on the next paint frame (CSS transition then does the decay)", () => {
|
||||
const { rerender } = render(
|
||||
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
|
||||
);
|
||||
rerender(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000000000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"true",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
rafCallback?.(0);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not re-trigger the pulse for an unchanged pulsedAt", () => {
|
||||
const { rerender } = render(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000000000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
rafCallback?.(0);
|
||||
});
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"false",
|
||||
);
|
||||
|
||||
// Same pulsedAt as before (e.g. an unrelated parent re-render) — must
|
||||
// stay cooled, not flash again.
|
||||
rerender(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000000000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-triggers on a newer pulsedAt after cooling down", () => {
|
||||
const { rerender } = render(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000000000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
rafCallback?.(0);
|
||||
});
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"false",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<A2APairCard
|
||||
pair={buildPair()}
|
||||
pulsedAt={1700000005000}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import {
|
||||
pairKey,
|
||||
pairMatchesFrame,
|
||||
latestPulseTimestamps,
|
||||
groupPairsBySection,
|
||||
sortPairsForSection,
|
||||
SECTION_LABELS,
|
||||
} from "../a2a-switchboard-utils";
|
||||
|
||||
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
|
||||
return {
|
||||
agent_a: "be-dev-1",
|
||||
role_a: "developer",
|
||||
team_a: "backend",
|
||||
agent_b: "be-qa",
|
||||
role_b: "qa",
|
||||
team_b: "backend",
|
||||
group_key: "cell-backend",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pairKey", () => {
|
||||
it("is order-independent", () => {
|
||||
expect(pairKey("be-dev-1", "be-qa")).toBe(pairKey("be-qa", "be-dev-1"));
|
||||
});
|
||||
|
||||
it("always puts the lexically smaller slug first", () => {
|
||||
expect(pairKey("be-qa", "be-dev-1")).toBe("be-dev-1|be-qa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairMatchesFrame", () => {
|
||||
it("matches regardless of from/to direction", () => {
|
||||
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-qa", "be-dev-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", "be-qa")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match a different pair", () => {
|
||||
expect(pairMatchesFrame("be-dev-1", "be-qa", "fe-dev-1", "fe-qa")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match when either side is missing", () => {
|
||||
expect(pairMatchesFrame("be-dev-1", "be-qa", undefined, "be-qa")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", null)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestPulseTimestamps", () => {
|
||||
const pairs = [
|
||||
buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" }),
|
||||
buildPair({
|
||||
agent_a: "fe-dev-1",
|
||||
agent_b: "fe-qa",
|
||||
group_key: "cell-frontend",
|
||||
}),
|
||||
];
|
||||
|
||||
it("returns the epoch ms of the matching frame for a pulsed pair", () => {
|
||||
const result = latestPulseTimestamps(
|
||||
[
|
||||
{
|
||||
from_agent: "be-dev-1",
|
||||
to_agent: "be-qa",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
],
|
||||
pairs,
|
||||
);
|
||||
expect(result[pairKey("be-dev-1", "be-qa")]).toBe(
|
||||
new Date("2026-07-02T10:00:00Z").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits pairs with no matching frame", () => {
|
||||
const result = latestPulseTimestamps(
|
||||
[
|
||||
{
|
||||
from_agent: "be-dev-1",
|
||||
to_agent: "be-qa",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
],
|
||||
pairs,
|
||||
);
|
||||
expect(result[pairKey("fe-dev-1", "fe-qa")]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps only the most recent matching frame per pair", () => {
|
||||
const result = latestPulseTimestamps(
|
||||
[
|
||||
{
|
||||
from_agent: "be-dev-1",
|
||||
to_agent: "be-qa",
|
||||
timestamp: "2026-07-02T09:00:00Z",
|
||||
},
|
||||
{
|
||||
from_agent: "be-qa",
|
||||
to_agent: "be-dev-1",
|
||||
timestamp: "2026-07-02T11:00:00Z",
|
||||
},
|
||||
],
|
||||
pairs,
|
||||
);
|
||||
expect(result[pairKey("be-dev-1", "be-qa")]).toBe(
|
||||
new Date("2026-07-02T11:00:00Z").getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores frames with an unparseable timestamp", () => {
|
||||
const result = latestPulseTimestamps(
|
||||
[{ from_agent: "be-dev-1", to_agent: "be-qa", timestamp: "not-a-date" }],
|
||||
pairs,
|
||||
);
|
||||
expect(result[pairKey("be-dev-1", "be-qa")]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty map for an empty frame list", () => {
|
||||
expect(latestPulseTimestamps([], pairs)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPairsForSection", () => {
|
||||
it("sorts pairs with history before never-talked pairs, most recent first", () => {
|
||||
const older = buildPair({
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-doc",
|
||||
conversation_id: "c1",
|
||||
last_message_at: "2026-07-01T00:00:00Z",
|
||||
});
|
||||
const newer = buildPair({
|
||||
agent_a: "be-dev-2",
|
||||
agent_b: "be-doc",
|
||||
conversation_id: "c2",
|
||||
last_message_at: "2026-07-02T00:00:00Z",
|
||||
});
|
||||
const neverTalked = buildPair({
|
||||
agent_a: "be-pm",
|
||||
agent_b: "be-qa",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
});
|
||||
|
||||
const sorted = sortPairsForSection([older, neverTalked, newer]);
|
||||
expect(sorted.map((p) => p.conversation_id)).toEqual(["c2", "c1", null]);
|
||||
});
|
||||
|
||||
it("breaks ties between never-talked pairs alphabetically", () => {
|
||||
const b = buildPair({ agent_a: "fe-dev-1", agent_b: "fe-qa" });
|
||||
const a = buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" });
|
||||
const sorted = sortPairsForSection([b, a]);
|
||||
expect(sorted[0]).toBe(a);
|
||||
expect(sorted[1]).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupPairsBySection", () => {
|
||||
it("groups pairs by group_key and orders sections canonically", () => {
|
||||
const pairs = [
|
||||
buildPair({ group_key: "cross", agent_a: "be-pm", agent_b: "fe-pm" }),
|
||||
buildPair({
|
||||
group_key: "board",
|
||||
agent_a: "auditor",
|
||||
agent_b: "product-owner",
|
||||
}),
|
||||
buildPair({ group_key: "cell-backend" }),
|
||||
buildPair({
|
||||
group_key: "pm-chain",
|
||||
agent_a: "be-pm",
|
||||
agent_b: "main-pm",
|
||||
}),
|
||||
];
|
||||
|
||||
const sections = groupPairsBySection(pairs);
|
||||
|
||||
expect(sections.map((s) => s.groupKey)).toEqual([
|
||||
"cell-backend",
|
||||
"pm-chain",
|
||||
"board",
|
||||
"cross",
|
||||
]);
|
||||
for (const section of sections) {
|
||||
expect(section.label).toBe(SECTION_LABELS[section.groupKey]);
|
||||
expect(section.pairs.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("appends unrecognized group keys after the canonical sections", () => {
|
||||
const pairs = [
|
||||
buildPair({ group_key: "cell-backend" }),
|
||||
buildPair({ group_key: "mystery-group" }),
|
||||
];
|
||||
const sections = groupPairsBySection(pairs);
|
||||
expect(sections.map((s) => s.groupKey)).toEqual([
|
||||
"cell-backend",
|
||||
"mystery-group",
|
||||
]);
|
||||
expect(sections[1].label).toBe("mystery-group");
|
||||
});
|
||||
|
||||
it("returns no sections for an empty pair list", () => {
|
||||
expect(groupPairsBySection([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { A2ASwitchboard } from "../a2a-switchboard";
|
||||
|
||||
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
|
||||
return {
|
||||
agent_a: "be-dev-1",
|
||||
role_a: "developer",
|
||||
team_a: "backend",
|
||||
agent_b: "be-qa",
|
||||
role_b: "qa",
|
||||
team_b: "backend",
|
||||
group_key: "cell-backend",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2ASwitchboard", () => {
|
||||
it("shows a loading skeleton grid", () => {
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={[]}
|
||||
pulses={{}}
|
||||
selectedConversationId={null}
|
||||
isLoading
|
||||
onOpenPair={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// No section headings or empty-state copy while loading.
|
||||
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/No allowed A2A pairs configured/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when there are no pairs", () => {
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={[]}
|
||||
pulses={{}}
|
||||
selectedConversationId={null}
|
||||
isLoading={false}
|
||||
onOpenPair={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No allowed A2A pairs configured"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups pairs into labeled sections with counts", () => {
|
||||
const pairs = [
|
||||
buildPair({ group_key: "cell-backend" }),
|
||||
buildPair({
|
||||
group_key: "cell-backend",
|
||||
agent_a: "be-dev-2",
|
||||
agent_b: "be-doc",
|
||||
}),
|
||||
buildPair({
|
||||
group_key: "board",
|
||||
agent_a: "auditor",
|
||||
agent_b: "product-owner",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={pairs}
|
||||
pulses={{}}
|
||||
selectedConversationId={null}
|
||||
isLoading={false}
|
||||
onOpenPair={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
|
||||
expect(screen.getByText("(2)")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
|
||||
expect(screen.getByText("(1)")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("pair-card")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("calls onOpenPair with the clicked pair", () => {
|
||||
const onOpenPair = vi.fn();
|
||||
const pair = buildPair({ conversation_id: "conv-9" });
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={[pair]}
|
||||
pulses={{}}
|
||||
selectedConversationId={null}
|
||||
isLoading={false}
|
||||
onOpenPair={onOpenPair}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("pair-card"));
|
||||
expect(onOpenPair).toHaveBeenCalledWith(pair);
|
||||
});
|
||||
|
||||
it("marks the card matching selectedConversationId as selected", () => {
|
||||
const pair = buildPair({ conversation_id: "conv-9" });
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={[pair]}
|
||||
pulses={{}}
|
||||
selectedConversationId="conv-9"
|
||||
isLoading={false}
|
||||
onOpenPair={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes each pair's pulse timestamp through by canonical pair key", () => {
|
||||
const pair = buildPair();
|
||||
render(
|
||||
<A2ASwitchboard
|
||||
pairs={[pair]}
|
||||
pulses={{ "be-dev-1|be-qa": 1700000000000 }}
|
||||
selectedConversationId={null}
|
||||
isLoading={false}
|
||||
onOpenPair={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pair-card")).toHaveAttribute(
|
||||
"data-pulsing",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PAIR_PULSE_FADE_MS } from "./a2a-switchboard-utils";
|
||||
|
||||
interface A2APairCardProps {
|
||||
pair: AdminPairSummary;
|
||||
/** Epoch ms of the latest a2a.message frame matching this pair, or null
|
||||
* when neither side has ever A2A'd the other (this session). */
|
||||
pulsedAt: number | null;
|
||||
isSelected?: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PairAvatar({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div
|
||||
className="h-7 w-7 rounded-full bg-primary/10 border flex items-center justify-center shrink-0"
|
||||
title={getAgentDisplayName(slug)}
|
||||
>
|
||||
<span className="text-[9px] font-bold tracking-tight">
|
||||
{getAgentInitials(slug)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One pair card in the A2A switchboard. Lights up on a matching a2a.message
|
||||
* frame — the card jumps to full "hot" intensity, then a plain CSS
|
||||
* transition (no animation library) fades it back to baseline over
|
||||
* PAIR_PULSE_FADE_MS. A never-talked pair (no conversation_id) renders
|
||||
* dimmed/compact.
|
||||
*/
|
||||
export function A2APairCard({
|
||||
pair,
|
||||
pulsedAt,
|
||||
isSelected,
|
||||
onOpen,
|
||||
}: A2APairCardProps) {
|
||||
const hasHistory = pair.conversation_id !== null;
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
|
||||
// Render-phase derivation, not an Effect (react.dev/learn/you-might-not-
|
||||
// need-an-effect#adjusting-some-state-when-a-prop-changes): flash hot in
|
||||
// the very same render that receives a new pulsedAt, comparing against the
|
||||
// last value we've seen. No cascading extra render from an Effect body.
|
||||
// Seeded to null (not the initial pulsedAt) so a card that *mounts*
|
||||
// already carrying a live pulse — e.g. switching into switchboard view
|
||||
// right after a frame arrived — still flashes hot instead of looking cold.
|
||||
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
|
||||
if (pulsedAt !== lastSeenPulse) {
|
||||
setLastSeenPulse(pulsedAt);
|
||||
if (pulsedAt !== null) setIsPulsing(true);
|
||||
}
|
||||
|
||||
// Flip back on the next paint frame — the long CSS transition-duration
|
||||
// below then animates the decay from "hot" to baseline over
|
||||
// PAIR_PULSE_FADE_MS. The setState here is inside the (async) rAF
|
||||
// callback, not the Effect body itself, so it's the intended "subscribe to
|
||||
// an external clock" use of an Effect.
|
||||
useEffect(() => {
|
||||
if (!isPulsing) return;
|
||||
const raf = requestAnimationFrame(() => setIsPulsing(false));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isPulsing]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
data-testid="pair-card"
|
||||
data-pulsing={isPulsing}
|
||||
data-group={pair.group_key}
|
||||
aria-pressed={!!isSelected}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg border p-2.5 cursor-pointer",
|
||||
"transition-[background-color,box-shadow] ease-out",
|
||||
isSelected ? "border-primary" : "border-border",
|
||||
!hasHistory && "opacity-60",
|
||||
isPulsing
|
||||
? "bg-emerald-500/15 shadow-[0_0_0_1px_rgba(16,185,129,0.6)]"
|
||||
: "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
<PairAvatar slug={pair.agent_a} />
|
||||
<PairAvatar slug={pair.agent_b} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{getAgentDisplayName(pair.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(pair.agent_b)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hasHistory && pair.last_message_at
|
||||
? `${formatDistanceToNow(new Date(pair.last_message_at))} ago`
|
||||
: "No A2A yet"}
|
||||
</div>
|
||||
</div>
|
||||
{hasHistory && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{pair.message_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Pure helpers for the A2A switchboard (org-chart pair cards) — extracted
|
||||
* for direct unit testing, same idiom as a2a-utils.ts.
|
||||
*/
|
||||
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
|
||||
/** How long a card stays visibly "hot" after a matching frame (CSS transition). */
|
||||
export const PAIR_PULSE_FADE_MS = 45_000;
|
||||
|
||||
/** Canonical, order-independent key for a pair — stable regardless of which
|
||||
* slug is passed first. */
|
||||
export function pairKey(agentA: string, agentB: string): string {
|
||||
return agentA < agentB ? `${agentA}|${agentB}` : `${agentB}|${agentA}`;
|
||||
}
|
||||
|
||||
/** True when an A2A live-stream frame's from/to agents are this unordered pair. */
|
||||
export function pairMatchesFrame(
|
||||
agentA: string,
|
||||
agentB: string,
|
||||
frameFrom?: string | null,
|
||||
frameTo?: string | null,
|
||||
): boolean {
|
||||
if (!frameFrom || !frameTo) return false;
|
||||
return (
|
||||
(frameFrom === agentA && frameTo === agentB) ||
|
||||
(frameFrom === agentB && frameTo === agentA)
|
||||
);
|
||||
}
|
||||
|
||||
interface PulseFrame {
|
||||
from_agent?: string | null;
|
||||
to_agent?: string | null;
|
||||
timestamp?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each pair, the epoch ms of the most recent matching frame — or absent
|
||||
* from the map when no frame has matched. Bounded: O(pairs * frames), both
|
||||
* small (the switchboard's static matrix, and the live-stream's capped
|
||||
* message buffer).
|
||||
*/
|
||||
export function latestPulseTimestamps(
|
||||
frames: ReadonlyArray<PulseFrame>,
|
||||
pairs: ReadonlyArray<Pick<AdminPairSummary, "agent_a" | "agent_b">>,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const pair of pairs) {
|
||||
const key = pairKey(pair.agent_a, pair.agent_b);
|
||||
for (const frame of frames) {
|
||||
if (
|
||||
!pairMatchesFrame(
|
||||
pair.agent_a,
|
||||
pair.agent_b,
|
||||
frame.from_agent,
|
||||
frame.to_agent,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const ts = frame.timestamp ? new Date(frame.timestamp).getTime() : NaN;
|
||||
if (Number.isNaN(ts)) continue;
|
||||
if (out[key] === undefined || ts > out[key]) out[key] = ts;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Stable section ordering — cells first (org-chart top-down), then the PM
|
||||
* chain, board, and finally the lateral catch-all. */
|
||||
export const SECTION_ORDER = [
|
||||
"cell-backend",
|
||||
"cell-frontend",
|
||||
"cell-ux_ui",
|
||||
"pm-chain",
|
||||
"board",
|
||||
"cross",
|
||||
] as const;
|
||||
|
||||
export const SECTION_LABELS: Record<string, string> = {
|
||||
"cell-backend": "Backend Cell",
|
||||
"cell-frontend": "Frontend Cell",
|
||||
"cell-ux_ui": "UX/UI Cell",
|
||||
"pm-chain": "PM Chain",
|
||||
board: "Board",
|
||||
cross: "Cross-Team",
|
||||
};
|
||||
|
||||
export interface PairSection {
|
||||
groupKey: string;
|
||||
label: string;
|
||||
pairs: AdminPairSummary[];
|
||||
}
|
||||
|
||||
/** Pairs with history sort first (most recent activity first); never-talked
|
||||
* pairs follow in a stable alphabetical order. */
|
||||
export function sortPairsForSection(
|
||||
pairs: ReadonlyArray<AdminPairSummary>,
|
||||
): AdminPairSummary[] {
|
||||
return [...pairs].sort((a, b) => {
|
||||
const aTime = a.last_message_at
|
||||
? new Date(a.last_message_at).getTime()
|
||||
: null;
|
||||
const bTime = b.last_message_at
|
||||
? new Date(b.last_message_at).getTime()
|
||||
: null;
|
||||
if (aTime !== null && bTime !== null) return bTime - aTime;
|
||||
if (aTime !== null) return -1;
|
||||
if (bTime !== null) return 1;
|
||||
return `${a.agent_a}${a.agent_b}`.localeCompare(`${b.agent_a}${b.agent_b}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Group pairs into ordered, labeled sections for the switchboard grid. */
|
||||
export function groupPairsBySection(
|
||||
pairs: ReadonlyArray<AdminPairSummary>,
|
||||
): PairSection[] {
|
||||
const byGroup = new Map<string, AdminPairSummary[]>();
|
||||
for (const pair of pairs) {
|
||||
const list = byGroup.get(pair.group_key) ?? [];
|
||||
list.push(pair);
|
||||
byGroup.set(pair.group_key, list);
|
||||
}
|
||||
|
||||
const known = SECTION_ORDER.filter((key) => byGroup.has(key));
|
||||
const unknown = [...byGroup.keys()].filter(
|
||||
(key) => !(SECTION_ORDER as readonly string[]).includes(key),
|
||||
);
|
||||
|
||||
return [...known, ...unknown].map((groupKey) => ({
|
||||
groupKey,
|
||||
label: SECTION_LABELS[groupKey] ?? groupKey,
|
||||
pairs: sortPairsForSection(byGroup.get(groupKey) ?? []),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Radio } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AdminPairSummary } from "@/lib/api/a2a";
|
||||
import { A2APairCard } from "./a2a-pair-card";
|
||||
import { groupPairsBySection, pairKey } from "./a2a-switchboard-utils";
|
||||
|
||||
interface A2ASwitchboardProps {
|
||||
pairs: AdminPairSummary[];
|
||||
/** pairKey(agent_a, agent_b) -> epoch ms of the latest matching frame. */
|
||||
pulses: Record<string, number>;
|
||||
selectedConversationId: string | null;
|
||||
isLoading: boolean;
|
||||
onOpenPair: (pair: AdminPairSummary) => void;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 9;
|
||||
|
||||
/**
|
||||
* The org-chart switchboard: every allowed agent pair as a card, grouped
|
||||
* into sections (each cell, the PM chain, board, cross-team) and sorted so
|
||||
* pairs with history come first within their section.
|
||||
*/
|
||||
export function A2ASwitchboard({
|
||||
pairs,
|
||||
pulses,
|
||||
selectedConversationId,
|
||||
isLoading,
|
||||
onOpenPair,
|
||||
}: A2ASwitchboardProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No allowed A2A pairs configured</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = groupPairsBySection(pairs);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-2 space-y-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.groupKey}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
|
||||
{section.label}
|
||||
<span className="ml-1.5 text-muted-foreground/60 normal-case">
|
||||
({section.pairs.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{section.pairs.map((pair) => {
|
||||
const key = pairKey(pair.agent_a, pair.agent_b);
|
||||
return (
|
||||
<A2APairCard
|
||||
key={key}
|
||||
pair={pair}
|
||||
pulsedAt={pulses[key] ?? null}
|
||||
isSelected={
|
||||
!!pair.conversation_id &&
|
||||
pair.conversation_id === selectedConversationId
|
||||
}
|
||||
onOpen={() => onOpenPair(pair)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { a2aApi, type AdminReplyRequest } from "@/lib/api/a2a";
|
||||
export const 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,
|
||||
};
|
||||
@@ -20,6 +21,17 @@ export function useA2AConversations(limit?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
// Switchboard pair cards (the org-chart view) — every allowed agent pair
|
||||
// joined with its representative conversation stats. Refreshed by WS
|
||||
// `a2a.message` invalidation, same as the conversation list.
|
||||
export function useA2AAdminPairs() {
|
||||
return useQuery({
|
||||
queryKey: a2aLiveKeys.pairs,
|
||||
queryFn: () => a2aApi.listAdminPairs(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Transcript for one conversation. WS frames for the selected conversation
|
||||
// invalidate this key; full bodies always come from REST (excerpts are capped).
|
||||
export function useA2AMessages(conversationId: string | null) {
|
||||
|
||||
@@ -103,6 +103,29 @@ export interface AdminConversationListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pair card for the CEO's A2A switchboard (org-chart view) — the static
|
||||
* can_a2a_direct matrix joined with the pair's representative conversation
|
||||
* stats when one exists.
|
||||
*/
|
||||
export interface AdminPairSummary {
|
||||
agent_a: string;
|
||||
role_a: string;
|
||||
team_a: string;
|
||||
agent_b: string;
|
||||
role_b: string;
|
||||
team_b: string;
|
||||
group_key: string;
|
||||
conversation_id: string | null;
|
||||
last_message_at: string | null;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface AdminPairListResponse {
|
||||
items: AdminPairSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminMessageListResponse {
|
||||
items: A2AChatMessage[];
|
||||
total: number;
|
||||
@@ -322,6 +345,49 @@ export const a2aApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* List the org-chart switchboard's pair cards (CEO-only): every agent pair
|
||||
* allowed to A2A directly, joined with each pair's representative
|
||||
* conversation stats when one exists.
|
||||
*/
|
||||
listAdminPairs: async (): Promise<AdminPairListResponse> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
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: "mock-conversation-1",
|
||||
last_message_at: new Date().toISOString(),
|
||||
message_count: 3,
|
||||
},
|
||||
{
|
||||
agent_a: "auditor",
|
||||
role_a: "auditor",
|
||||
team_a: "board",
|
||||
agent_b: "product-owner",
|
||||
role_b: "product_owner",
|
||||
team_b: "board",
|
||||
group_key: "board",
|
||||
conversation_id: null,
|
||||
last_message_at: null,
|
||||
message_count: 0,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<AdminPairListResponse>(
|
||||
"/a2a/chat/admin/pairs",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a CEO reply. Lands in the CEO<->to_agent pairwise conversation (the
|
||||
* A2A model is strictly pairwise), NOT inside the watched transcript.
|
||||
|
||||
Reference in New Issue
Block a user