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

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

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

* docs(map): panel entries for this wave

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 07:06:32 +02:00
committed by GitHub
co-authored by Renn F
parent 1a726d4c34
commit 4480dfe8d1
15 changed files with 1885 additions and 1382 deletions
@@ -1,472 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { PageRefreshProvider } from "@/components/providers";
import type {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
import { describe, it, expect, vi } from "vitest";
const {
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2AAdminPairs: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams("conversation=conv-1"),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
}));
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
// Select — irrelevant to this suite, stub it out like create-task-dialog's
// suite does for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
vi.mock("@/hooks/use-websocket", () => ({
useA2ALiveStream,
}));
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
// so this suite doesn't need a real QueryClientProvider.
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect }));
import A2APage from "../page";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 2,
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2APage", () => {
beforeEach(() => {
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(withPageRefresh(<A2APage />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("shows the switchboard by default with pair cards grouped into sections", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
it("toggles to the classic conversation list and back", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("Conversations")).toBeInTheDocument();
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2APage />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2APage />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2APage />));
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("renders the filter trigger above the switchboard/list content", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("narrows the switchboard's pairs by a selected agent", async () => {
const user = userEvent.setup();
useA2AAdminPairs.mockReturnValue({
data: {
items: [
buildPair(),
buildPair({
agent_a: "auditor",
agent_b: "product-owner",
group_key: "board",
conversation_id: null,
last_message_at: null,
message_count: 0,
}),
],
total: 2,
},
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
});
it("narrows the classic list's conversations by a selected agent", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation(),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
topic: "Design review",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
});
it("renders the New DM trigger in the header", () => {
render(withPageRefresh(<A2APage />));
expect(
screen.getByRole("button", { name: /new dm/i }),
).toBeInTheDocument();
});
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
// A CEO-initiated DM has no task link and no picker — it must render
// A2ADirectComposer, not the task-gated A2AReplyComposer.
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
agent_a: "ceo",
agent_b: "be-dev-1",
task_id: null,
}),
],
total: 1,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/no linked task, so a reply can't be sent/i),
).not.toBeInTheDocument();
});
it("narrows the classic list's conversations by task id fragment", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
task_id: "11111111-2222-3333-4444-555555555555",
}),
buildConversation({
id: "conv-2",
topic: "Design review",
task_id: "99999999-8888-7777-6666-555555555555",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2APage />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
it("redirects to the Agents hub's Conversations tab", () => {
A2APage();
expect(redirect).toHaveBeenCalledWith("/agents?tab=conversations");
});
});
+4 -580
View File
@@ -1,583 +1,7 @@
"use client";
import { redirect } from "next/navigation";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AAdminPairs,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { usePageRefresh } from "@/hooks";
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
import { A2ADirectComposer } from "@/components/a2a/a2a-direct-composer";
import { A2ANewDmDialog } from "@/components/a2a/a2a-new-dm-dialog";
import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
import {
A2AConnectionBadge,
A2AConnectionBanner,
} from "@/components/a2a/a2a-connection-badge";
import { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-utils";
import {
distinctA2AAgents,
filterConversations,
filterPairs,
EMPTY_A2A_FILTERS,
type A2AFilters,
} from "@/components/a2a/a2a-filter-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { CEO_SLUG, lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Radio,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
type A2AView = "switchboard" | "list";
interface PeekedPair {
agent_a: string;
agent_b: string;
}
function A2APageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const selectedId = searchParams.get("conversation");
// Desktop default is the switchboard (org-chart pair cards); the classic
// list stays one click away as the mobile/compact fallback.
const [view, setView] = useState<A2AView>("switchboard");
// A pair with no conversation yet, clicked from the switchboard — there is
// nothing to select via `?conversation=`, so it's tracked separately and
// shown as an explicit "no A2A yet" state in the drill-in panel.
const [peekedPair, setPeekedPair] = useState<PeekedPair | null>(null);
// Filter panel: Agent, Task (id fragment + no-linked-task), Status, Date
// range — narrows both the switchboard's pairs (Agent only) and the
// list's conversations (all four), per the design doc's per-view rules.
const [filters, setFilters] = useState<A2AFilters>(EMPTY_A2A_FILTERS);
// xl:+ context pane collapse, persisted via the shared UI store — same
// idiom as sidebar/theme preferences (design doc §1).
const contextOpen = useUIStore((s) => s.a2aContextOpen);
const toggleContext = useUIStore((s) => s.toggleA2AContext);
// Reconnecting/disconnected banner strip, dismissable per occurrence — it
// reappears the next time the connection drops (design doc §3). Render-phase
// reset (compared against the previous connectionState, same idiom as
// A2APairCard's usePulseFlash) rather than an effect.
const [bannerDismissed, setBannerDismissed] = useState(false);
const [lastConnectionState, setLastConnectionState] = useState<string | null>(
null,
);
const {
data: conversationData,
isLoading: loadingConversations,
error,
refetch: refetchConversations,
} = useA2AConversations(100);
const {
data: pairsData,
isLoading: loadingPairs,
refetch: refetchPairs,
} = useA2AAdminPairs();
const {
data: messagesData,
isLoading: loadingMessages,
error: messagesError,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchConversations();
},
() => {
void refetchPairs();
},
() => {
void refetchMessages();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchConversations,
refetchPairs,
refetchMessages,
]);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
// react-query refetches the affected queries.
const {
lastMessage,
a2aMessages,
isConnected,
state: connectionState,
} = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.pairs });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
});
}
}, [lastMessage, queryClient, selectedId]);
// Re-arm the dismissable banner the next time the connection actually
// drops, rather than leaving it dismissed forever after the first hiccup.
if (connectionState !== lastConnectionState) {
setLastConnectionState(connectionState);
if (
connectionState !== "reconnecting" &&
connectionState !== "disconnected"
) {
setBannerDismissed(false);
}
}
// On /ws/system reconnect (false → true) the A2A list is stale — events
// missed during the disconnect. Invalidate the a2a query family so
// react-query refetches. Initial mount with isConnected=true does NOT
// fire (prevConnected starts unknown, not false).
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
if (prevConnected.current === false && isConnected) {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
}
prevConnected.current = isConnected;
}, [isConnected, queryClient]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
const pulses = useMemo(
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const filteredPairs = useMemo(
() => filterPairs(pairs, filters),
[pairs, filters],
);
const handleSelect = useCallback(
(id: string) => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/a2a?${params.toString()}`);
},
[router, searchParams],
);
const handleOpenPair = useCallback(
(pair: AdminPairSummary) => {
if (pair.conversation_id) {
handleSelect(pair.conversation_id);
return;
}
// Never-talked pair: nothing to select, clear any prior selection and
// show the pair's own empty state instead.
setPeekedPair({ agent_a: pair.agent_a, agent_b: pair.agent_b });
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
},
[handleSelect, router, searchParams],
);
const conversations = useMemo(
() => conversationData?.items ?? [],
[conversationData],
);
const filteredConversations = useMemo(
() => filterConversations(conversations, filters),
[conversations, filters],
);
const agentOptions = useMemo(
() => distinctA2AAgents(conversations, pairs),
[conversations, pairs],
);
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const messages = messagesData?.items ?? [];
const lastSender = lastSenderOf(messages);
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
}, [router, searchParams]);
return (
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
<p className="text-muted-foreground">
Live agent-to-agent conversations watch and chime in
</p>
</div>
<div className="flex items-center gap-4">
<A2ANewDmDialog onCreated={handleSelect} />
<A2AConnectionBadge state={connectionState} />
{/* Context pane never appears below xl — its toggle is hidden
there too, matching the switchboard/list toggle's placement
idiom (design doc §1). */}
<HelpTip
label={contextOpen ? "Hide context panel" : "Show context panel"}
>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
aria-label={
contextOpen ? "Hide context panel" : "Show context panel"
}
title={contextOpen ? "Hide context panel" : "Show context panel"}
>
{contextOpen ? (
<PanelRightClose className="h-3.5 w-3.5" />
) : (
<PanelRightOpen className="h-3.5 w-3.5" />
)}
</Button>
</HelpTip>
</div>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<HelpTip label="Returns to the switchboard/list">
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</HelpTip>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
contextOpen && "xl:col-span-3",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
aria-label="Switchboard: org-chart pair cards"
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
aria-label="Classic conversation list"
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div>
<A2AFilterBar
filters={filters}
onFiltersChange={setFilters}
agentOptions={agentOptions}
view={view}
/>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={filteredPairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={filteredConversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
pulses={pulses}
/>
)}
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
contextOpen && "xl:col-span-6",
onDetailLevel ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
{peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<>
{selected && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<HelpTip label={selected.status === "active" ? "Actively exchanging messages" : "No longer active"}>
<Badge
variant={
selected.status === "active"
? "default"
: "secondary"
}
className="text-xs w-fit"
>
{selected.status}
</Badge>
</HelpTip>
<HelpTip label={new Date(selected.updated_at).toLocaleString()}>
<span className="text-xs text-muted-foreground ml-auto w-fit">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
ago
</span>
</HelpTip>
</div>
)}
{/* Scoped to the stream pane, not a full-page takeover —
a live-connection hint, distinct from OfflineState. */}
{(connectionState === "reconnecting" ||
connectionState === "disconnected") &&
!bannerDismissed && (
<div className="-mx-3 mb-3">
<A2AConnectionBanner
state={connectionState}
onDismiss={() => setBannerDismissed(true)}
/>
</div>
)}
{/* All three loading/empty/error states live inside
A2ATranscript now — the pane chrome above stays mounted
and stable while only this area swaps (design doc §5). */}
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
hasSelection={!!selected}
error={!!messagesError}
onRetry={() => void refetchMessages()}
/>
</div>
{/* Composer: a conversation the CEO itself owns (opened
via "New DM") always gets the direct composer — it's
the CEO's own thread, not something being watched, so
no task link is required. Otherwise this is a watched
agent<->agent conversation: the backend's reply route
rejects with 400 exactly when it has no task link
(replies ride the gateway send path, which requires
one), so a task-less one is read-only — say why instead
of letting the send bounce. Status does NOT gate either
composer: a reply lands in the CEO's own direct thread
with the participant, not in the watched conversation. */}
{selected && (
<div className="shrink-0 border-t -mx-3">
{selected.agent_a === CEO_SLUG ||
selected.agent_b === CEO_SLUG ? (
<A2ADirectComposer
key={selected.id}
conversationId={selected.id}
otherAgent={
selected.agent_a === CEO_SLUG
? selected.agent_b
: selected.agent_a
}
/>
) : selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped
to a task).
</div>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Panel 3: Context (xl:+ only, dismissible) — participant
identity cards + linked-task summary, read-only (design doc
§1). */}
{contextOpen && (
<Card className="hidden xl:col-span-3 xl:flex xl:flex-col overflow-hidden">
<CardContent className="p-0 flex-1 overflow-y-auto">
{selected ? (
<A2AContextPane
agentA={selected.agent_a}
agentB={selected.agent_b}
taskId={selected.task_id}
/>
) : peekedPair ? (
<A2AContextPane
agentA={peekedPair.agent_a}
agentB={peekedPair.agent_b}
taskId={null}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground p-4 text-center text-sm">
Select a conversation to see participant details
</div>
)}
</CardContent>
</Card>
)}
</div>
</>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
// A2A merged into the Agents hub as its Conversations tab (CEO decision,
// wave 9) — this route now just forwards old links/bookmarks.
export default function A2APage() {
return (
<Suspense
fallback={
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-4">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-8" />
</div>
</div>
}
>
<A2APageContent />
</Suspense>
);
redirect("/agents?tab=conversations");
}
@@ -1,130 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// CEO feedback round 2: Total Agents must reflect the full roster, not the
// orchestrator's live-instance count, and Board + Main PM must fold into one
// "Leadership" band instead of a lone Main PM card wasting a full row.
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
// The two tab panes have their own dedicated tests — stub them here so this
// page test only checks tab composition + the URL-driven default, mirroring
// workstation/__tests__/page.test.tsx.
vi.mock("@/components/agents/agents-fleet-view", () => ({
AgentsFleetView: () => <div>AgentsFleetViewStub</div>,
}));
vi.mock("@/components/a2a/a2a-view", () => ({
A2AView: () => <div>A2AViewStub</div>,
}));
vi.mock("@/hooks/use-usage", () => ({
useAgentUsage: () => ({ data: [] }),
}));
const { useOrchestratorStatus, useWaitingAgents, useAgentDefinitions } =
vi.hoisted(() => ({
useOrchestratorStatus: vi.fn(),
useWaitingAgents: vi.fn(),
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
}));
vi.mock("@/components/agents", () => ({
OrchestratorStatusCards: ({ rosterCount }: { rosterCount: number }) => (
<div data-testid="orchestrator-status-cards" data-roster-count={rosterCount} />
),
WaitingAgentsAlert: () => <div data-testid="waiting-agents-alert" />,
AgentGrid: ({
title,
agents,
}: {
title: string;
agents: AgentDefinition[];
}) => (
<div data-testid={"grid-" + title}>
{agents.map((a) => a.id).join(",")}
</div>
),
const mockReplace = vi.fn();
let searchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => searchParams,
}));
import AgentsPage from "../page";
const AGENTS: AgentDefinition[] = [
{
id: "product-owner",
name: "Product Owner",
role: "product_owner" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "head-marketing",
name: "Head of Marketing",
role: "head_marketing" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "auditor",
name: "Auditor",
role: "auditor" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "main-pm",
name: "Main PM",
role: "main_pm" as AgentDefinition["role"],
team: "main_pm" as AgentDefinition["team"],
},
{
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer" as AgentDefinition["role"],
team: "backend" as AgentDefinition["team"],
},
];
describe("AgentsPage", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
useOrchestratorStatus.mockReturnValue({
data: {
total_agents: 2, // deliberately far below the roster size
by_state: { active: 2 },
waiting_count: 0,
agents: [],
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
useWaitingAgents.mockReturnValue({ data: undefined });
searchParams = new URLSearchParams();
mockReplace.mockClear();
});
it("passes the full roster size as the truthful Total Agents count, not the backend's live-instance total", () => {
it("defaults to the Fleet tab when the URL carries no ?tab", () => {
render(<AgentsPage />);
const cards = screen.getByTestId("orchestrator-status-cards");
expect(cards).toHaveAttribute("data-roster-count", "5");
});
it("folds Board and Main PM into one Leadership group instead of separate sections", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Leadership")).toHaveTextContent(
"product-owner,head-marketing,auditor,main-pm",
expect(screen.getByRole("tab", { name: "Fleet" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.queryByTestId("grid-Board")).not.toBeInTheDocument();
expect(screen.queryByTestId("grid-Main PM")).not.toBeInTheDocument();
});
it("still renders the per-cell grids", () => {
render(<AgentsPage />);
expect(screen.getByTestId("grid-Backend Cell")).toHaveTextContent(
"be-dev-1",
expect(screen.getByRole("tab", { name: "Conversations" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("AgentsFleetViewStub")).toBeInTheDocument();
});
it("does not render the Support grid when no support agents match", () => {
it("activates the Conversations tab from ?tab=conversations", () => {
searchParams = new URLSearchParams("tab=conversations");
render(<AgentsPage />);
expect(screen.queryByTestId("grid-Support")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Conversations" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "Fleet" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("A2AViewStub")).toBeInTheDocument();
});
});
+95 -142
View File
@@ -1,159 +1,112 @@
"use client";
import { useEffect, useMemo } from "react";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
import { useAgentUsage } from "@/hooks/use-usage";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
getSupportAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
WaitingAgentsAlert,
AgentGrid,
} from "@/components/agents";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentsFleetView } from "@/components/agents/agents-fleet-view";
import { A2AView } from "@/components/a2a/a2a-view";
export default function AgentsPage() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
const { data: usageRows } = useAgentUsage();
// ---------------------------------------------------------------------------
// Valid tab values
// ---------------------------------------------------------------------------
const { register, unregister, refresh } = usePageRefresh();
interface TabDef {
value: "fleet" | "conversations";
label: string;
hint: string;
}
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
const TAB_DEFS: TabDef[] = [
{
value: "fleet",
label: "Fleet",
hint: "Every agent's live state, spawn controls, and activity stream",
},
{
value: "conversations",
label: "Conversations",
hint: "Live agent-to-agent message switchboard and history",
},
];
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number];
// Convert agents array to a record keyed by agent_id for easy lookup
const agentStatuses = useMemo(() => {
const result: Record<string, AgentStatusResponse> = {};
if (status?.agents) {
for (const agent of status.agents) {
result[agent.agent_id] = agent;
}
}
return result;
}, [status]);
function isValidTab(value: string | null): value is TabValue {
return TAB_VALUES.includes(value as TabValue);
}
// Convert usage rows to a record keyed by agent_slug
const agentUsageMap = useMemo(() => {
const result: Record<string, AgentUsageRow> = {};
for (const row of usageRows ?? []) {
result[row.agent_slug] = row;
}
return result;
}, [usageRows]);
// ---------------------------------------------------------------------------
// Inner component that reads URL params
// ---------------------------------------------------------------------------
function AgentsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const rawTab = searchParams.get("tab");
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "fleet";
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`/agents?${params.toString()}`);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
<p className="text-muted-foreground">
Monitor and control your AI workforce
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{TAB_DEFS.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
own data-state; re-assert the real selection state
explicitly (see task-detail/task-tabs.tsx) so the
data-[state=active] styling still fires. */}
<TabsTrigger
value={tab.value}
data-state={tab.value === activeTab ? "active" : "inactive"}
>
{tab.label}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Status Overview — Total Agents is the full roster size, not the
orchestrator's live-instance count, so it stays truthful even
when most of the roster isn't currently spawned. */}
<OrchestratorStatusCards
status={status}
isLoading={isLoading}
rosterCount={agents.length}
rosterLoading={agentsLoading}
/>
<TabsContent value="fleet" className="mt-4">
<AgentsFleetView />
</TabsContent>
{/* Waiting Agents Alert */}
{waitingAgents && (
<WaitingAgentsAlert waitingAgents={waitingAgents} />
)}
</>
)}
<TabsContent value="conversations" className="mt-4">
<A2AView />
</TabsContent>
</Tabs>
);
}
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
one Leadership band so a lone Main PM card never wastes a full row. */}
<AgentGrid
title="Leadership"
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
// ---------------------------------------------------------------------------
// Page export — wraps in Suspense for useSearchParams
// ---------------------------------------------------------------------------
<AgentGrid
title="Backend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Frontend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="UX/UI Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
and the root PR Reviewer — only rendered when at least one matches */}
{getSupportAgents(agents).length > 0 && (
<AgentGrid
title="Support"
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
agents={getSupportAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
)}
</div>
export default function AgentsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<AgentsPageContent />
</Suspense>
);
}
@@ -4,6 +4,15 @@ import React from "react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-agents", () => ({
useAgentDefinitions: () => ({
data: [
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
{ id: "auditor", name: "Auditor", role: "auditor", team: "board" },
],
}),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
useCreateCeoConversation: () => ({ mutate, isPending: false }),
}));
@@ -100,4 +109,77 @@ describe("A2ANewDmDialog", () => {
screen.queryByRole("button", { name: /start conversation/i }),
).not.toBeInTheDocument();
});
describe("controlled open + initialTarget (DM quick-action deep link)", () => {
it("stays closed by default when open=false, and opens with the target preselected once open=true", () => {
const onOpenChange = vi.fn();
const { rerender } = render(
<A2ANewDmDialog
onCreated={vi.fn()}
open={false}
onOpenChange={onOpenChange}
initialTarget="be-dev-1"
/>,
);
expect(
screen.queryByRole("button", { name: /start conversation/i }),
).not.toBeInTheDocument();
rerender(
<A2ANewDmDialog
onCreated={vi.fn()}
open={true}
onOpenChange={onOpenChange}
initialTarget="be-dev-1"
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("be-dev-1");
});
it("routes trigger clicks and Escape/close through the caller's onOpenChange, not internal state", () => {
const onOpenChange = vi.fn();
render(
<A2ANewDmDialog
onCreated={vi.fn()}
open={true}
onOpenChange={onOpenChange}
initialTarget={null}
/>,
);
// The trigger sits behind Radix's aria-hidden focus-trap boundary
// while the dialog is open, so it must be queried with hidden: true.
fireEvent.click(
screen.getByRole("button", { name: /new dm/i, hidden: true }),
);
// Radix requests a state change; the controlled caller decides — this
// dialog never flips itself open/closed while controlled.
expect(onOpenChange).toHaveBeenCalled();
});
});
});
describe("initialTarget validation (untrusted URL input)", () => {
it("does not preselect an excluded role deep-linked via ?dm=", () => {
render(
<A2ANewDmDialog
open
onOpenChange={() => {}}
initialTarget="auditor"
onCreated={() => {}}
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("");
});
it("does not preselect an unknown slug", () => {
render(
<A2ANewDmDialog
open
onOpenChange={() => {}}
initialTarget="doesnotexist"
onCreated={() => {}}
/>,
);
expect(screen.getByLabelText("Agent")).toHaveValue("");
});
});
@@ -0,0 +1,542 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { PageRefreshProvider } from "@/components/providers";
import type {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
// Extracted from the standalone /a2a page (now the Agents hub's
// Conversations tab, see agents/page.tsx) — pure lift, same suite, new
// import, plus a new describe block for the `?dm=` quick-action handshake.
const {
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2AAdminPairs: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
}));
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Stable objects (like real next/navigation — useRouter()'s return value and
// useSearchParams()'s per-URL value only change reference on an actual
// navigation, not on every render), same idiom as
// workstation/__tests__/page.test.tsx's `searchParams` variable. An unstable
// mock here would make the dm-param effect (deps: [dmParam, router,
// searchParams]) refire every render regardless of the real URL.
const mockRouter = { push: mockPush, replace: mockReplace };
let searchParams = new URLSearchParams("conversation=conv-1");
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
useSearchParams: () => searchParams,
}));
vi.mock("@/hooks/use-agents", () => ({
useAgentDefinitions: () => ({
data: [
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
],
}),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
}));
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
// Select — irrelevant to this suite, stub it out like create-task-dialog's
// suite does for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
vi.mock("@/hooks/use-websocket", () => ({
useA2ALiveStream,
}));
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
// so this suite doesn't need a real QueryClientProvider.
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { A2AView } from "../a2a-view";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
function buildPair(
overrides: Partial<AdminPairSummary> = {},
): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 2,
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2AView", () => {
beforeEach(() => {
searchParams = new URLSearchParams("conversation=conv-1");
mockPush.mockClear();
mockReplace.mockClear();
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(withPageRefresh(<A2AView />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("shows the switchboard by default with pair cards grouped into sections", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
it("toggles to the classic conversation list and back", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("Conversations")).toBeInTheDocument();
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2AView />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2AView />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("renders the filter trigger above the switchboard/list content", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("narrows the switchboard's pairs by a selected agent", async () => {
const user = userEvent.setup();
useA2AAdminPairs.mockReturnValue({
data: {
items: [
buildPair(),
buildPair({
agent_a: "auditor",
agent_b: "product-owner",
group_key: "board",
conversation_id: null,
last_message_at: null,
message_count: 0,
}),
],
total: 2,
},
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
});
it("narrows the classic list's conversations by a selected agent", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation(),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
topic: "Design review",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
});
it("renders the New DM trigger in the header", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.getByRole("button", { name: /new dm/i }),
).toBeInTheDocument();
});
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
// A CEO-initiated DM has no task link and no picker — it must render
// A2ADirectComposer, not the task-gated A2AReplyComposer.
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
agent_a: "ceo",
agent_b: "be-dev-1",
task_id: null,
}),
],
total: 1,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/no linked task, so a reply can't be sent/i),
).not.toBeInTheDocument();
});
it("narrows the classic list's conversations by task id fragment", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
task_id: "11111111-2222-3333-4444-555555555555",
}),
buildConversation({
id: "conv-2",
topic: "Design review",
task_id: "99999999-8888-7777-6666-555555555555",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
});
// DM quick-action deep link: the agent card's DM button lands here as
// `?tab=conversations&dm=<agent slug>`.
describe("`?dm=` quick-action handshake", () => {
it("opens the New DM dialog and strips the dm param, keeping the rest of the query string", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(screen.getByText("New direct message")).toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith("/agents?tab=conversations", {
scroll: false,
});
});
it("does not open the dialog when no dm param is present", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.queryByText("New direct message"),
).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("re-arms for a repeated identical dm value after the strip (latch reset)", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
const { rerender } = render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(1);
// The strip landed: same mounted view, dm gone from the URL.
searchParams = new URLSearchParams("tab=conversations");
rerender(withPageRefresh(<A2AView />));
// The SAME dm value arrives again (re-pasted link / second click
// without a tab remount) — the handshake must fire again.
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
rerender(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(2);
});
it("drops to a bare /agents path when dm was the only param", () => {
searchParams = new URLSearchParams("dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledWith("/agents", { scroll: false });
});
});
});
+38 -2
View File
@@ -21,6 +21,7 @@ import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
import { useAgentDefinitions } from "@/hooks/use-agents";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
@@ -39,6 +40,13 @@ interface A2ANewDmDialogProps {
/** Called with the new (or reopened) conversation's id once the CEO's
* first message is sent — the caller selects/opens it in the page. */
onCreated: (conversationId: string) => void;
/** Controlled-open pair — omit both for the default uncontrolled trigger-
* button behavior (internal state). Pass both to drive the dialog from
* outside, e.g. the agent card's DM quick-action deep link. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Preselects this agent as the target whenever the dialog opens. */
initialTarget?: string | null;
}
/**
@@ -46,8 +54,15 @@ interface A2ANewDmDialogProps {
* classic list only ever show conversations that already exist; this is the
* one surface that creates one, addressed to any agent (never itself).
*/
export function A2ANewDmDialog({ onCreated }: A2ANewDmDialogProps) {
const [open, setOpen] = useState(false);
export function A2ANewDmDialog({
onCreated,
open: openProp,
onOpenChange,
initialTarget,
}: A2ANewDmDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = openProp ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const [targetAgent, setTargetAgent] = useState<string | null>(null);
const [message, setMessage] = useState("");
const create = useCreateCeoConversation();
@@ -57,6 +72,27 @@ export function A2ANewDmDialog({ onCreated }: A2ANewDmDialogProps) {
setMessage("");
};
// A deep-linked initialTarget is untrusted URL input (?dm=<anything>):
// only preselect an agent that exists on the roster AND can receive a DM.
// An excluded role, an unknown slug, or a still-loading roster opens the
// dialog unselected instead — safe over convenient.
const { data: roster = [] } = useAgentDefinitions();
const preselectable =
!!initialTarget &&
roster.some(
(a) =>
a.id === initialTarget &&
(!a.role || !EXCLUDE_NON_DM_ROLES.includes(a.role)),
);
// Preselect on the open transition (render-phase adjustment, not an
// effect — same idiom as the connection-banner reset in a2a-view.tsx).
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open && initialTarget && preselectable) setTargetAgent(initialTarget);
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = message.trim();
+627
View File
@@ -0,0 +1,627 @@
"use client";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AAdminPairs,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { usePageRefresh } from "@/hooks";
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
import { A2ADirectComposer } from "@/components/a2a/a2a-direct-composer";
import { A2ANewDmDialog } from "@/components/a2a/a2a-new-dm-dialog";
import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
import {
A2AConnectionBadge,
A2AConnectionBanner,
} from "@/components/a2a/a2a-connection-badge";
import { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-utils";
import {
distinctA2AAgents,
filterConversations,
filterPairs,
EMPTY_A2A_FILTERS,
type A2AFilters,
} from "@/components/a2a/a2a-filter-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { CEO_SLUG, lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Radio,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
type A2AView = "switchboard" | "list";
interface PeekedPair {
agent_a: string;
agent_b: string;
}
/** Conversations tab content — extracted from the standalone /a2a page so it
* can live inside the Agents hub tab shell (see agents/page.tsx). Its own
* `?conversation=` param keeps working on the /agents route; every writer
* below targets /agents (not /a2a) preserving the rest of the query string
* (e.g. `tab=conversations`). */
function A2AViewContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const selectedId = searchParams.get("conversation");
// Desktop default is the switchboard (org-chart pair cards); the classic
// list stays one click away as the mobile/compact fallback.
const [view, setView] = useState<A2AView>("switchboard");
// A pair with no conversation yet, clicked from the switchboard — there is
// nothing to select via `?conversation=`, so it's tracked separately and
// shown as an explicit "no A2A yet" state in the drill-in panel.
const [peekedPair, setPeekedPair] = useState<PeekedPair | null>(null);
// Filter panel: Agent, Task (id fragment + no-linked-task), Status, Date
// range — narrows both the switchboard's pairs (Agent only) and the
// list's conversations (all four), per the design doc's per-view rules.
const [filters, setFilters] = useState<A2AFilters>(EMPTY_A2A_FILTERS);
// xl:+ context pane collapse, persisted via the shared UI store — same
// idiom as sidebar/theme preferences (design doc §1).
const contextOpen = useUIStore((s) => s.a2aContextOpen);
const toggleContext = useUIStore((s) => s.toggleA2AContext);
// Reconnecting/disconnected banner strip, dismissable per occurrence — it
// reappears the next time the connection drops (design doc §3). Render-phase
// reset (compared against the previous connectionState, same idiom as
// A2APairCard's usePulseFlash) rather than an effect.
const [bannerDismissed, setBannerDismissed] = useState(false);
const [lastConnectionState, setLastConnectionState] = useState<string | null>(
null,
);
// DM quick-action deep link: the agent card's DM button lands here as
// `?tab=conversations&dm=<agent slug>`. Consumed exactly once — open the
// New DM dialog preselected at that agent, then strip `dm` so a refresh
// doesn't reopen it. The open/target state is set during render (a
// prevDmParam comparison, same idiom as the connection-banner reset below)
// rather than an effect; only the actual URL-stripping side effect below
// needs a real effect.
const dmParam = searchParams.get("dm");
const [dmDialogOpen, setDmDialogOpen] = useState(false);
const [dmTarget, setDmTarget] = useState<string | null>(null);
const [prevDmParam, setPrevDmParam] = useState<string | null>(null);
if (dmParam && dmParam !== prevDmParam) {
setPrevDmParam(dmParam);
setDmTarget(dmParam);
setDmDialogOpen(true);
} else if (!dmParam && prevDmParam !== null) {
// Re-arm once the strip removes `dm` — without this the latch holds the
// last value forever and a repeated identical deep link (re-pasted URL,
// second DM click without a tab remount) silently does nothing.
setPrevDmParam(null);
}
const {
data: conversationData,
isLoading: loadingConversations,
error,
refetch: refetchConversations,
} = useA2AConversations(100);
const {
data: pairsData,
isLoading: loadingPairs,
refetch: refetchPairs,
} = useA2AAdminPairs();
const {
data: messagesData,
isLoading: loadingMessages,
error: messagesError,
refetch: refetchMessages,
} = useA2AMessages(selectedId);
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const callbacks = [
() => {
void refetchConversations();
},
() => {
void refetchPairs();
},
() => {
void refetchMessages();
},
];
callbacks.forEach((cb) => register(cb));
return () => {
callbacks.forEach((cb) => unregister(cb));
};
}, [
register,
unregister,
refetchConversations,
refetchPairs,
refetchMessages,
]);
// Live wiring: every persisted A2A message is announced on /ws/system as an
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
// react-query refetches the affected queries.
const {
lastMessage,
a2aMessages,
isConnected,
state: connectionState,
} = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.pairs });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
});
}
}, [lastMessage, queryClient, selectedId]);
// Re-arm the dismissable banner the next time the connection actually
// drops, rather than leaving it dismissed forever after the first hiccup.
if (connectionState !== lastConnectionState) {
setLastConnectionState(connectionState);
if (
connectionState !== "reconnecting" &&
connectionState !== "disconnected"
) {
setBannerDismissed(false);
}
}
// On /ws/system reconnect (false → true) the A2A list is stale — events
// missed during the disconnect. Invalidate the a2a query family so
// react-query refetches. Initial mount with isConnected=true does NOT
// fire (prevConnected starts unknown, not false).
const prevConnected = useRef<boolean | null>(null);
useEffect(() => {
if (prevConnected.current === false && isConnected) {
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.all });
}
prevConnected.current = isConnected;
}, [isConnected, queryClient]);
// Strip `dm` from the URL once it's been picked up above — this effect
// does nothing but sync the URL to an external system (the router), so it
// stays clear of the set-state-in-effect rule; it naturally stops firing
// once the param is gone (dmParam becomes null / the dependency changes).
useEffect(() => {
if (!dmParam) return;
const params = new URLSearchParams(searchParams.toString());
params.delete("dm");
const qs = params.toString();
router.replace(qs ? `/agents?${qs}` : "/agents", { scroll: false });
}, [dmParam, router, searchParams]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
const pulses = useMemo(
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const filteredPairs = useMemo(
() => filterPairs(pairs, filters),
[pairs, filters],
);
const handleSelect = useCallback(
(id: string) => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/agents?${params.toString()}`);
},
[router, searchParams],
);
const handleOpenPair = useCallback(
(pair: AdminPairSummary) => {
if (pair.conversation_id) {
handleSelect(pair.conversation_id);
return;
}
// Never-talked pair: nothing to select, clear any prior selection and
// show the pair's own empty state instead.
setPeekedPair({ agent_a: pair.agent_a, agent_b: pair.agent_b });
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/agents?${qs}` : "/agents");
},
[handleSelect, router, searchParams],
);
const conversations = useMemo(
() => conversationData?.items ?? [],
[conversationData],
);
const filteredConversations = useMemo(
() => filterConversations(conversations, filters),
[conversations, filters],
);
const agentOptions = useMemo(
() => distinctA2AAgents(conversations, pairs),
[conversations, pairs],
);
const selected = conversations.find((c) => c.id === selectedId) ?? null;
const messages = messagesData?.items ?? [];
const lastSender = lastSenderOf(messages);
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/agents?${qs}` : "/agents");
}, [router, searchParams]);
return (
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
<p className="text-muted-foreground">
Live agent-to-agent conversations watch and chime in
</p>
</div>
<div className="flex items-center gap-4">
<A2ANewDmDialog
onCreated={handleSelect}
open={dmDialogOpen}
onOpenChange={setDmDialogOpen}
initialTarget={dmTarget}
/>
<A2AConnectionBadge state={connectionState} />
{/* Context pane never appears below xl — its toggle is hidden
there too, matching the switchboard/list toggle's placement
idiom (design doc §1). */}
<HelpTip
label={contextOpen ? "Hide context panel" : "Show context panel"}
>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
aria-label={
contextOpen ? "Hide context panel" : "Show context panel"
}
title={contextOpen ? "Hide context panel" : "Show context panel"}
>
{contextOpen ? (
<PanelRightClose className="h-3.5 w-3.5" />
) : (
<PanelRightOpen className="h-3.5 w-3.5" />
)}
</Button>
</HelpTip>
</div>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load A2A Conversations"
description="Start the RoboCo orchestrator to view agent-to-agent chats."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<HelpTip label="Returns to the switchboard/list">
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</HelpTip>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
contextOpen && "xl:col-span-3",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
aria-label="Switchboard: org-chart pair cards"
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
aria-label="Classic conversation list"
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div>
<A2AFilterBar
filters={filters}
onFiltersChange={setFilters}
agentOptions={agentOptions}
view={view}
/>
<div className="flex-1 overflow-hidden -mx-3">
{view === "switchboard" ? (
<A2ASwitchboard
pairs={filteredPairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={filteredConversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
pulses={pulses}
/>
)}
</div>
</CardContent>
</Card>
{/* Panel 2: Transcript + composer */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
contextOpen && "xl:col-span-6",
onDetailLevel ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
{peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<>
{selected && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{getAgentDisplayName(selected.agent_a)}
{" ↔ "}
{getAgentDisplayName(selected.agent_b)}
</span>
<HelpTip label={selected.status === "active" ? "Actively exchanging messages" : "No longer active"}>
<Badge
variant={
selected.status === "active"
? "default"
: "secondary"
}
className="text-xs w-fit"
>
{selected.status}
</Badge>
</HelpTip>
<HelpTip label={new Date(selected.updated_at).toLocaleString()}>
<span className="text-xs text-muted-foreground ml-auto w-fit">
{selected.message_count} msgs · updated{" "}
{formatDistanceToNow(new Date(selected.updated_at))}{" "}
ago
</span>
</HelpTip>
</div>
)}
{/* Scoped to the stream pane, not a full-page takeover —
a live-connection hint, distinct from OfflineState. */}
{(connectionState === "reconnecting" ||
connectionState === "disconnected") &&
!bannerDismissed && (
<div className="-mx-3 mb-3">
<A2AConnectionBanner
state={connectionState}
onDismiss={() => setBannerDismissed(true)}
/>
</div>
)}
{/* All three loading/empty/error states live inside
A2ATranscript now — the pane chrome above stays mounted
and stable while only this area swaps (design doc §5). */}
<div className="flex-1 overflow-hidden -mx-3">
<A2ATranscript
messages={messages}
isLoading={loadingMessages}
hasSelection={!!selected}
error={!!messagesError}
onRetry={() => void refetchMessages()}
/>
</div>
{/* Composer: a conversation the CEO itself owns (opened
via "New DM") always gets the direct composer — it's
the CEO's own thread, not something being watched, so
no task link is required. Otherwise this is a watched
agent<->agent conversation: the backend's reply route
rejects with 400 exactly when it has no task link
(replies ride the gateway send path, which requires
one), so a task-less one is read-only — say why instead
of letting the send bounce. Status does NOT gate either
composer: a reply lands in the CEO's own direct thread
with the participant, not in the watched conversation. */}
{selected && (
<div className="shrink-0 border-t -mx-3">
{selected.agent_a === CEO_SLUG ||
selected.agent_b === CEO_SLUG ? (
<A2ADirectComposer
key={selected.id}
conversationId={selected.id}
otherAgent={
selected.agent_a === CEO_SLUG
? selected.agent_b
: selected.agent_a
}
/>
) : selected.task_id ? (
<A2AReplyComposer
key={selected.id}
conversationId={selected.id}
agentA={selected.agent_a}
agentB={selected.agent_b}
lastSender={lastSender}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped
to a task).
</div>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Panel 3: Context (xl:+ only, dismissible) — participant
identity cards + linked-task summary, read-only (design doc
§1). */}
{contextOpen && (
<Card className="hidden xl:col-span-3 xl:flex xl:flex-col overflow-hidden">
<CardContent className="p-0 flex-1 overflow-y-auto">
{selected ? (
<A2AContextPane
agentA={selected.agent_a}
agentB={selected.agent_b}
taskId={selected.task_id}
/>
) : peekedPair ? (
<A2AContextPane
agentA={peekedPair.agent_a}
agentB={peekedPair.agent_b}
taskId={null}
/>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground p-4 text-center text-sm">
Select a conversation to see participant details
</div>
)}
</CardContent>
</Card>
)}
</div>
</>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export function A2AView() {
return (
<Suspense
fallback={
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-4">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-8" />
</div>
</div>
}
>
<A2AViewContent />
</Suspense>
);
}
@@ -8,6 +8,11 @@ vi.mock("@/hooks/use-agents", () => ({
useStopAgent: () => ({ mutateAsync: vi.fn() }),
}));
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
@@ -171,4 +176,39 @@ describe("AgentCard", () => {
expect(screen.getByText(/12\.3K tok/)).toBeInTheDocument();
expect(screen.getByText(/\$0\.0421/)).toBeInTheDocument();
});
it("offers a DM quick-action that jumps to Conversations pre-targeted at this agent", async () => {
const user = userEvent.setup();
render(<AgentCard agent={AGENT} agentStatus={statusOf()} />);
await user.click(screen.getByRole("button", { name: "DM this agent" }));
expect(mockPush).toHaveBeenCalledWith(
"/agents?tab=conversations&dm=be-dev-1",
);
});
it("hides the DM quick-action for a role that can't read/answer a DM", () => {
const auditor = {
id: "auditor",
name: "Auditor",
role: "auditor",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={auditor} agentStatus={statusOf()} />);
expect(
screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument();
});
it("hides the DM quick-action for the CEO card", () => {
const ceo = {
id: "ceo",
name: "CEO",
role: "ceo",
team: "board",
} as unknown as AgentDefinition;
render(<AgentCard agent={ceo} agentStatus={statusOf()} />);
expect(
screen.queryByRole("button", { name: "DM this agent" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentDefinition } from "@/lib/agent-definitions";
// CEO feedback round 2: Total Agents must reflect the full roster, not the
// orchestrator's live-instance count, and Board + Main PM must fold into one
// "Leadership" band instead of a lone Main PM card wasting a full row.
//
// Extracted from the standalone /agents page (now the Agents hub's Fleet
// tab, see agents/page.tsx) — pure lift, same suite, new import.
vi.mock("@/hooks/use-page-refresh", () => ({
usePageRefresh: () => ({
register: vi.fn(),
unregister: vi.fn(),
refresh: vi.fn(),
}),
}));
vi.mock("@/hooks/use-usage", () => ({
useAgentUsage: () => ({ data: [] }),
}));
const { useOrchestratorStatus, useWaitingAgents, useAgentDefinitions } =
vi.hoisted(() => ({
useOrchestratorStatus: vi.fn(),
useWaitingAgents: vi.fn(),
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
}));
vi.mock("@/components/agents", () => ({
OrchestratorStatusCards: ({ rosterCount }: { rosterCount: number }) => (
<div data-testid="orchestrator-status-cards" data-roster-count={rosterCount} />
),
WaitingAgentsAlert: () => <div data-testid="waiting-agents-alert" />,
AgentGrid: ({
title,
agents,
}: {
title: string;
agents: AgentDefinition[];
}) => (
<div data-testid={"grid-" + title}>
{agents.map((a) => a.id).join(",")}
</div>
),
}));
import { AgentsFleetView } from "../agents-fleet-view";
const AGENTS: AgentDefinition[] = [
{
id: "product-owner",
name: "Product Owner",
role: "product_owner" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "head-marketing",
name: "Head of Marketing",
role: "head_marketing" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "auditor",
name: "Auditor",
role: "auditor" as AgentDefinition["role"],
team: "board" as AgentDefinition["team"],
},
{
id: "main-pm",
name: "Main PM",
role: "main_pm" as AgentDefinition["role"],
team: "main_pm" as AgentDefinition["team"],
},
{
id: "be-dev-1",
name: "Backend Dev 1",
role: "developer" as AgentDefinition["role"],
team: "backend" as AgentDefinition["team"],
},
];
describe("AgentsFleetView", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
useOrchestratorStatus.mockReturnValue({
data: {
total_agents: 2, // deliberately far below the roster size
by_state: { active: 2 },
waiting_count: 0,
agents: [],
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
});
useWaitingAgents.mockReturnValue({ data: undefined });
});
it("passes the full roster size as the truthful Total Agents count, not the backend's live-instance total", () => {
render(<AgentsFleetView />);
const cards = screen.getByTestId("orchestrator-status-cards");
expect(cards).toHaveAttribute("data-roster-count", "5");
});
it("folds Board and Main PM into one Leadership group instead of separate sections", () => {
render(<AgentsFleetView />);
expect(screen.getByTestId("grid-Leadership")).toHaveTextContent(
"product-owner,head-marketing,auditor,main-pm",
);
expect(screen.queryByTestId("grid-Board")).not.toBeInTheDocument();
expect(screen.queryByTestId("grid-Main PM")).not.toBeInTheDocument();
});
it("still renders the per-cell grids", () => {
render(<AgentsFleetView />);
expect(screen.getByTestId("grid-Backend Cell")).toHaveTextContent(
"be-dev-1",
);
});
it("does not render the Support grid when no support agents match", () => {
render(<AgentsFleetView />);
expect(screen.queryByTestId("grid-Support")).not.toBeInTheDocument();
});
});
+68 -44
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useStopAgent } from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
@@ -20,11 +21,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Activity, Square } from "lucide-react";
import { MoreHorizontal, Activity, MessageSquare, Square } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { agentStateDescription, stateColors } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
import type { AgentUsageRow } from "@/types";
interface AgentCardProps {
@@ -34,8 +36,12 @@ interface AgentCardProps {
}
export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
const router = useRouter();
const stopAgent = useStopAgent();
const state = agentStatus?.state || "stopped";
// Same exclusion list the New DM dialog enforces (self, plus roles that
// can't read/answer a DM) — a card for one of those renders no DM button.
const canDm = !!agent.role && !EXCLUDE_NON_DM_ROLES.includes(agent.role);
// "Up" = anything that isn't a terminal/down state. Spawn is offered ONLY when
// the agent is down; an up agent (active / running / idle / paused / …) shows
// View Details + Stop instead. We list the DOWN states rather than the up ones
@@ -78,54 +84,72 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
<CardTitle className="truncate text-base">
{agent.name || "Unknown Agent"}
</CardTitle>
<DropdownMenu>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
<div className="flex shrink-0 items-center gap-0.5">
{canDm && (
<HelpTip label="DM this agent">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
title="Agent actions"
className="h-6 w-6"
aria-label="DM this agent"
title="DM this agent"
onClick={() =>
router.push(`/agents?tab=conversations&dm=${agent.id}`)
}
>
<MoreHorizontal className="h-3.5 w-3.5" />
<MessageSquare className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
)}
{isActive && (
<>
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
<DropdownMenuItem asChild>
<Link href={"/agents/" + agent.id} prefetch={false}>
<Activity className="h-4 w-4 mr-2" />
View Details
</Link>
</DropdownMenuItem>
</HelpTip>
<DropdownMenuSeparator />
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
<DropdownMenuItem onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop Gracefully
</DropdownMenuItem>
</HelpTip>
<HelpTip label="Kills the container immediately, even mid-task" side="left">
<DropdownMenuItem
onClick={() => handleStop(false)}
className="text-red-600"
>
<Square className="h-4 w-4 mr-2" />
Force Stop
</DropdownMenuItem>
</HelpTip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</HelpTip>
)}
<DropdownMenu>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
title="Agent actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
)}
{isActive && (
<>
<HelpTip label="Open this agent's status, activity, and live output stream" side="left">
<DropdownMenuItem asChild>
<Link href={"/agents/" + agent.id} prefetch={false}>
<Activity className="h-4 w-4 mr-2" />
View Details
</Link>
</DropdownMenuItem>
</HelpTip>
<DropdownMenuSeparator />
<HelpTip label="Lets the agent finish its current step before stopping" side="left">
<DropdownMenuItem onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop Gracefully
</DropdownMenuItem>
</HelpTip>
<HelpTip label="Kills the container immediately, even mid-task" side="left">
<DropdownMenuItem
onClick={() => handleStop(false)}
className="text-red-600"
>
<Square className="h-4 w-4 mr-2" />
Force Stop
</DropdownMenuItem>
</HelpTip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="truncate text-xs">
{agent.role?.replace(/_/g, " ") || "N/A"}
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useMemo } from "react";
import {
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
import { useAgentUsage } from "@/hooks/use-usage";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { usePageRefresh } from "@/hooks";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
getSupportAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
WaitingAgentsAlert,
AgentGrid,
} from "@/components/agents";
/** Fleet tab content — extracted from the standalone /agents page so it can
* live inside the Agents hub tab shell (see agents/page.tsx). */
export function AgentsFleetView() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
const { data: usageRows } = useAgentUsage();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Convert agents array to a record keyed by agent_id for easy lookup
const agentStatuses = useMemo(() => {
const result: Record<string, AgentStatusResponse> = {};
if (status?.agents) {
for (const agent of status.agents) {
result[agent.agent_id] = agent;
}
}
return result;
}, [status]);
// Convert usage rows to a record keyed by agent_slug
const agentUsageMap = useMemo(() => {
const result: Record<string, AgentUsageRow> = {};
for (const row of usageRows ?? []) {
result[row.agent_slug] = row;
}
return result;
}, [usageRows]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
<p className="text-muted-foreground">
Monitor and control your AI workforce
</p>
</div>
</div>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => void refresh()}
/>
) : (
<>
{/* Status Overview — Total Agents is the full roster size, not the
orchestrator's live-instance count, so it stays truthful even
when most of the roster isn't currently spawned. */}
<OrchestratorStatusCards
status={status}
isLoading={isLoading}
rosterCount={agents.length}
rosterLoading={agentsLoading}
/>
{/* Waiting Agents Alert */}
{waitingAgents && (
<WaitingAgentsAlert waitingAgents={waitingAgents} />
)}
</>
)}
{/* Agent Grids - Dynamically loaded from API. Board + Main PM fold into
one Leadership band so a lone Main PM card never wastes a full row. */}
<AgentGrid
title="Leadership"
titleHint="Board (Product Owner, Head of Marketing, Auditor) plus the Main PM"
agents={[...getBoardAgents(agents), ...getMainPm(agents)]}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Backend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="Frontend Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
<AgentGrid
title="UX/UI Cell"
titleHint="2 Devs, 1 QA, 1 PM, 1 Documenter, 1 PR Reviewer"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
{/* Support section: the CEO-direct helpers — Intake/Prompter, Secretary,
and the root PR Reviewer — only rendered when at least one matches */}
{getSupportAgents(agents).length > 0 && (
<AgentGrid
title="Support"
titleHint="CEO-direct helpers: Intake/Prompter, Secretary, and the root PR Reviewer"
agents={getSupportAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
/>
)}
</div>
);
}
+1 -8
View File
@@ -20,7 +20,6 @@ import {
Cpu,
Sparkles,
Building2,
Radio,
Share2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -86,17 +85,11 @@ export const navItems = [
icon: Database,
tip: "Search the RAG corpus — playbooks, learnings, and vault notes",
},
{
title: "A2A",
href: "/a2a",
icon: Radio,
tip: "Live agent-to-agent message switchboard and history",
},
{
title: "Agents",
href: "/agents",
icon: Bot,
tip: "Every agent's live state, spawn controls, and activity stream",
tip: "Every agent's live state, spawn controls, and A2A conversations",
},
{
title: "Journals",