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