mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)
* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and fanned through the existing /ws/system bridge; CEO-only admin REST for conversations/messages + a reply route on the publish-bearing send path; panel /a2a page with live transcript and a composer gated on task-linked conversations. The matrix gains its one asymmetric rule: CEO may message anyone, nobody may target the CEO — and agent replies inside a CEO-opened conversation are hard-budgeted to one per CEO message (per conversation, per agent), rejected with wait-don't-retry guidance. Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the map delta. * feat(prompter): intake remembers the task history Intake spawns now carry a per-project chronological digest of recent tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens) merged into the ambient layer, and the interviewer gets a bounded search_past_tasks tool (one shared implementation behind the grok MCP tool and the Claude SDK in-process tool) to check precedent mid-conversation. Informational memory only — the sequencing analyzer keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed; pre-existing conventions-ambient MegaTask-scope gap flagged, untouched. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { AdminConversationSummary } from "@/lib/api/a2a";
|
||||
import { A2AConversationList } from "../a2a-conversation-list";
|
||||
|
||||
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: 7,
|
||||
last_message_at: "2026-07-02T09:00:00Z",
|
||||
last_message_preview: "Tests are green on the branch.",
|
||||
created_at: "2026-07-01T08:00:00Z",
|
||||
updated_at: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2AConversationList", () => {
|
||||
it("renders participants, relative time, preview, status badge and task chip", () => {
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Participants via getAgentDisplayName ("{a} <-> {b}").
|
||||
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
|
||||
// Topic, preview, message count, relative timestamp.
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Tests are green on the branch."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("7 msgs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago$/)).toBeInTheDocument();
|
||||
// Status badge.
|
||||
expect(screen.getByText("active")).toBeInTheDocument();
|
||||
// Task chip links to the task page.
|
||||
const chip = screen.getByRole("link", { name: /Task 11111111/ });
|
||||
expect(chip).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
});
|
||||
|
||||
it("fires onSelect with the conversation id on row click", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onSelect).toHaveBeenCalledWith("conv-1");
|
||||
});
|
||||
|
||||
it("does not hijack row selection when the task chip is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no conversations", () => {
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[]}
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
useReplyAsCeo: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Make the Select testable without Radix's portal/pointer machinery: each
|
||||
// SelectItem renders a button carrying its value; clicking it invokes the
|
||||
// nearest Select's onValueChange (scoped via context).
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Ctx = React.createContext<(v: string) => void>(() => {});
|
||||
return {
|
||||
Select: ({
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Ctx.Provider value={onValueChange ?? (() => {})}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(Ctx);
|
||||
return (
|
||||
<button data-value={value} onClick={() => onValueChange(value)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { A2AReplyComposer } from "../a2a-reply-composer";
|
||||
|
||||
function renderComposer(lastSender: string | null = "be-qa") {
|
||||
return render(
|
||||
<A2AReplyComposer
|
||||
conversationId="conv-1"
|
||||
agentA="be-dev-1"
|
||||
agentB="be-qa"
|
||||
lastSender={lastSender}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("A2AReplyComposer", () => {
|
||||
beforeEach(() => {
|
||||
mutate.mockReset();
|
||||
});
|
||||
|
||||
it("disables Send when the textarea is empty", () => {
|
||||
renderComposer();
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends { to_agent, content } defaulting to the last message's sender", () => {
|
||||
renderComposer("be-qa");
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Ship it" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "conv-1",
|
||||
to_agent: "be-qa",
|
||||
content: "Ship it",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to agent_a when there is no last sender", () => {
|
||||
renderComposer(null);
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Status?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to_agent: "be-dev-1", content: "Status?" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends to an explicitly selected participant", () => {
|
||||
const { container } = renderComposer("be-qa");
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-value="be-dev-1"]')
|
||||
?.click();
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Over to you" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to_agent: "be-dev-1" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("states the pairwise seam honestly in the helper text", () => {
|
||||
renderComposer();
|
||||
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
|
||||
// not an injection into the watched transcript.
|
||||
expect(
|
||||
screen.getByText(
|
||||
/direct A2A message from you to the selected participant/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
|
||||
vi.mock("@/components/ui/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import { A2ATranscript } from "../a2a-transcript";
|
||||
|
||||
function buildMessage(overrides: Partial<A2AChatMessage>): A2AChatMessage {
|
||||
return {
|
||||
id: "m1",
|
||||
conversation_id: "conv-1",
|
||||
from_agent: "be-dev-1",
|
||||
content: "hello",
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: "2026-07-02T10:00:00Z",
|
||||
edited_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2ATranscript", () => {
|
||||
it("renders messages chronologically with sender names and timestamps", () => {
|
||||
// Deliberately unordered payload: the later message first.
|
||||
const { container } = render(
|
||||
<A2ATranscript
|
||||
messages={[
|
||||
buildMessage({
|
||||
id: "m2",
|
||||
from_agent: "be-qa",
|
||||
content: "second message body",
|
||||
created_at: "2026-07-02T10:05:00Z",
|
||||
}),
|
||||
buildMessage({
|
||||
id: "m1",
|
||||
from_agent: "be-dev-1",
|
||||
content: "first message body",
|
||||
created_at: "2026-07-02T10:00:00Z",
|
||||
}),
|
||||
]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend QA")).toBeInTheDocument();
|
||||
// Every message carries a relative timestamp.
|
||||
expect(screen.getAllByText(/ago$/)).toHaveLength(2);
|
||||
// Chronological order: oldest first regardless of payload order.
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("first message body")).toBeLessThan(
|
||||
text.indexOf("second message body"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the message kind as an outline badge when present", () => {
|
||||
render(
|
||||
<A2ATranscript
|
||||
messages={[buildMessage({ message_kind: "escalation" })]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("escalation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no messages", () => {
|
||||
render(<A2ATranscript messages={[]} isLoading={false} />);
|
||||
expect(
|
||||
screen.getByText(/No messages in this conversation yet/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lastSenderOf, pickDefaultRecipient } from "../a2a-utils";
|
||||
|
||||
describe("lastSenderOf", () => {
|
||||
it("returns null for an empty transcript", () => {
|
||||
expect(lastSenderOf([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the chronologically latest sender even when payload is unordered", () => {
|
||||
expect(
|
||||
lastSenderOf([
|
||||
{ from_agent: "be-qa", created_at: "2026-07-02T10:05:00Z" },
|
||||
{ from_agent: "be-dev-1", created_at: "2026-07-02T10:00:00Z" },
|
||||
]),
|
||||
).toBe("be-qa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultRecipient", () => {
|
||||
it("picks the last sender when they are a participant", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", "be-qa")).toBe("be-qa");
|
||||
});
|
||||
|
||||
it("falls back to agent_a when the transcript is empty", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", null)).toBe("be-dev-1");
|
||||
});
|
||||
|
||||
it("falls back to agent_a when the last sender is not a participant", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", "fe-dev-1")).toBe(
|
||||
"be-dev-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import type { AdminConversationSummary } from "@/lib/api/a2a";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ListTodo, MessagesSquare } from "lucide-react";
|
||||
|
||||
interface A2AConversationListProps {
|
||||
conversations: AdminConversationSummary[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function A2AConversationList({
|
||||
conversations,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: A2AConversationListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No A2A conversations yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"block w-full cursor-pointer p-3 rounded-lg border transition-all " +
|
||||
(selectedId === conversation.id
|
||||
? "bg-primary/10 border-primary"
|
||||
: "bg-card hover:bg-muted/50 hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{getAgentDisplayName(conversation.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(conversation.agent_b)}
|
||||
</div>
|
||||
{conversation.topic && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{conversation.topic}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(
|
||||
new Date(
|
||||
conversation.last_message_at ?? conversation.created_at,
|
||||
),
|
||||
)}{" "}
|
||||
ago
|
||||
</div>
|
||||
{conversation.last_message_preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{conversation.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
{conversation.task_id && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${conversation.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
||||
>
|
||||
<ListTodo className="h-3 w-3" />
|
||||
Task {conversation.task_id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={
|
||||
conversation.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{conversation.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.message_count} msgs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useReplyAsCeo } from "@/hooks/use-a2a-live";
|
||||
import { pickDefaultRecipient } from "./a2a-utils";
|
||||
|
||||
interface A2AReplyComposerProps {
|
||||
conversationId: string;
|
||||
agentA: string;
|
||||
agentB: string;
|
||||
/** Slug of the sender of the latest transcript message (default recipient). */
|
||||
lastSender: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function A2AReplyComposer({
|
||||
conversationId,
|
||||
agentA,
|
||||
agentB,
|
||||
lastSender,
|
||||
disabled,
|
||||
}: A2AReplyComposerProps) {
|
||||
const [content, setContent] = useState("");
|
||||
// null = follow the default (last sender) until the CEO picks explicitly.
|
||||
const [chosenRecipient, setChosenRecipient] = useState<string | null>(null);
|
||||
const reply = useReplyAsCeo();
|
||||
|
||||
const recipient =
|
||||
chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || reply.isPending) return;
|
||||
|
||||
reply.mutate(
|
||||
{ conversationId, to_agent: recipient, content: trimmed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Reply sent to ${getAgentDisplayName(recipient)}`);
|
||||
setContent("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Chime in... (Shift+Enter for new line)"
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={disabled || reply.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={recipient} onValueChange={setChosenRecipient}>
|
||||
<SelectTrigger className="w-auto min-w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[agentA, agentB].map((slug) => (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{getAgentDisplayName(slug)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!content.trim() || disabled || reply.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Sends a direct A2A message from you to the selected participant. It
|
||||
lands in your own conversation with that agent, not inside this
|
||||
transcript.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
|
||||
interface A2ATranscriptProps {
|
||||
messages: A2AChatMessage[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
// Chronological (oldest first) regardless of payload ordering.
|
||||
const sorted = [...messages].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom only once on initial load.
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && sorted.length > 0 && !hasScrolledRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
hasScrolledRef.current = true;
|
||||
}
|
||||
}, [sorted.length]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32 mb-2" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No messages in this conversation yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{sorted.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(message.from_agent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm">
|
||||
{getAgentDisplayName(message.from_agent)}
|
||||
</span>
|
||||
{message.message_kind && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{message.message_kind}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDistanceToNow(new Date(message.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Pure helpers for the A2A live view (extracted for direct unit testing).
|
||||
*/
|
||||
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
/**
|
||||
* Slug of the sender of the chronologically latest message, or null when the
|
||||
* transcript is empty. Sorts defensively — the API contract is oldest-first,
|
||||
* but the default-recipient pick must not depend on payload ordering.
|
||||
*/
|
||||
export function lastSenderOf(
|
||||
messages: ReadonlyArray<Pick<A2AChatMessage, "from_agent" | "created_at">>,
|
||||
): string | null {
|
||||
if (messages.length === 0) return null;
|
||||
const sorted = [...messages].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
);
|
||||
return sorted[sorted.length - 1].from_agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reply recipient: the participant who spoke last (the natural
|
||||
* "answer them" target), falling back to agent_a when the transcript is empty
|
||||
* or the last sender is not one of the two participants.
|
||||
*/
|
||||
export function pickDefaultRecipient(
|
||||
agentA: string,
|
||||
agentB: string,
|
||||
lastSender: string | null,
|
||||
): string {
|
||||
if (lastSender === agentA || lastSender === agentB) return lastSender;
|
||||
return agentA;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Cpu,
|
||||
Sparkles,
|
||||
Building2,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -50,6 +51,7 @@ export const navItems = [
|
||||
|
||||
// History
|
||||
{ title: "Communications", href: "/communications", icon: MessageSquare },
|
||||
{ title: "A2A Live", href: "/a2a", icon: Radio },
|
||||
{ title: "Journals", href: "/journals", icon: BookOpen },
|
||||
|
||||
// System
|
||||
|
||||
Reference in New Issue
Block a user