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",