[a360b6e3] Redesign A2A page with conversation-first layout and agent identity (#401)

* [54b94e44] A2A page: filter controls + agent identity consistency (#387) (#392)

* [54b94e44] feat(a2a): add filter bar and unify agent avatars + pulse across views

Adds a status (active/all) + free-text search filter bar above the A2A
switchboard/list content, backed by a shared a2a-filter-utils module so
both A2ASwitchboard's pairs and A2AConversationList's conversations
narrow identically. Extracts A2APairCard's pulse-flash state into a
reusable usePulseFlash hook and exports its PairAvatar so the classic
conversation list now renders the same two-participant avatar and
emerald pulse-flash affordance the switchboard already had.

* [54b94e44] docs(a2a): add comprehensive filtering and avatar documentation

Documented the new A2A filter bar, filter utilities, pulse-flash hook, and
conversation list API changes. Includes examples, testing guidance, and
migration notes for the pulses prop requirement.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [54417f0c] UX/UI: design A2A conversation-first layout and agent identity (#399)

* [f612a5ab] Add conversation-first layout, agent identity, and live-stream affordance spec (#384)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [7ed2ef71] docs(ux_ui): add filter-control design spec for A2A conversations (#383)

Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>

* [f563bbc9] Implement conversation-first A2A layout, identity colors, connection states, transcript motion, and empty/error states (#423) (#427)

* [f563bbc9] feat(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states

Implements docs/ux_ui/design/02-conversation-first-layout-agent-identity-live-stream.md:
- xl:+ collapsible Context pane (identity cards, linked-task summary, no-task hint), persisted via the existing zustand ui-store
- getAgentTeamColor + TEAM_COLOR_CLASSES in agent-utils.ts, applied to PairAvatar, the transcript row avatar, and the context pane
- A2AConnectionBadge/A2AConnectionBanner rendering all four ConnectionState values distinctly with a motion-reduce-guarded pulsing dot and a dismissable reconnecting/disconnected strip
- A2ATranscript: transform/opacity-only new-row entrance transition, scrolled-up "New messages" pill, split hasSelection/empty/error states with a scoped Retry
- Unit tests for every new pure helper and component

* [f563bbc9] docs(a2a): conversation-first layout, team-color identity, connection states, transcript motion, empty/error states

Document the new conversation-first A2A layout features:
- Agent team-color system (getAgentTeamColor, TEAM_COLOR_CLASSES) for six cell buckets
- A2AContextPane component with identity cards, linked task summary, no-task hint
- Connection state rendering (A2AConnectionBadge, A2AConnectionBanner) for all four ConnectionState values
- Transcript entrance motion with transform/opacity-only transitions and prefers-reduced-motion guards
- Split empty/error states (no selection, no messages, fetch error with scoped retry)
- Page-level integration with xl:+ responsive grid layout

Includes component API, usage examples, testing guidance, accessibility notes, and design rationale.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [478f027c] Implement A2A conversations filter control per conversations-filter-control.md (#445) (#448)

* [478f027c] feat(a2a): add multi-dimension Popover filter panel for A2A conversations

Replace the free-text search + active/all toggle with the Popover-triggered
filter control from conversations-filter-control.md: Agent multi-select
checkboxes, a Task id-fragment input with a "No linked task" toggle, Status
toggle buttons, and a date range, plus an active-filter chip row and Clear
all. Filtering applies to both the switchboard (Agent only) and conversation
list (all four dimensions) per the design doc's per-view rules.

* [478f027c] docs(a2a): add comprehensive filter-control guide covering component API, filter dimensions, and per-view rules

Documents A2AFilterBar component and filter utilities with:
- Component API and props
- All 4 filter dimensions (Agent, Task, Status, Date range)
- Per-view rules (Switchboard vs List)
- Usage examples and parent setup
- Filter logic and match predicates
- Testing guide and accessibility notes
- Design notes on client-side filtering limitation
- Links to related components and the design spec

Helps developers understand, use, and maintain the A2A conversations
filter control without needing to read the design doc or component source.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Developer 2 <ux-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 07:46:29 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Frontend Documenter UX/UI Developer 1 UX/UI Developer 2 Frontend Developer 2 Renn F
parent 58354a364e
commit 6e57066bd6
33 changed files with 3999 additions and 552 deletions
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ConnectionState } from "@/lib/websocket/connection";
import {
A2AConnectionBadge,
A2AConnectionBanner,
} from "../a2a-connection-badge";
describe("A2AConnectionBadge", () => {
it.each([
["connected", "Live"],
["connecting", "Connecting…"],
["reconnecting", "Reconnecting…"],
["disconnected", "Offline"],
] satisfies [ConnectionState, string][])(
"renders a distinct label for %s",
(state, label) => {
render(<A2AConnectionBadge state={state} />);
expect(screen.getByText(label)).toBeInTheDocument();
},
);
});
describe("A2AConnectionBanner", () => {
it("reads 'Reconnecting' for the reconnecting state", () => {
render(<A2AConnectionBanner state="reconnecting" onDismiss={vi.fn()} />);
expect(
screen.getByText(/Reconnecting — messages may be out of date/),
).toBeInTheDocument();
});
it("reads 'Disconnected' for the disconnected state", () => {
render(<A2AConnectionBanner state="disconnected" onDismiss={vi.fn()} />);
expect(
screen.getByText(/Disconnected — reconnecting automatically/),
).toBeInTheDocument();
});
it("dismiss button fires onDismiss", () => {
const onDismiss = vi.fn();
render(<A2AConnectionBanner state="disconnected" onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "task-1",
title: "Ship the context pane",
description: "",
acceptance_criteria: [],
status: TaskStatus.IN_PROGRESS,
priority: 1,
project_id: "proj-1",
task_type: TaskType.CODE,
team: Team.FRONTEND,
assigned_to: null,
parent_task_id: null,
branch_name: null,
pr_number: null,
pr_url: null,
created_at: "2026-07-02T10:00:00Z",
updated_at: "2026-07-02T10:00:00Z",
...overrides,
} as Task;
}
let mockTask: Task | undefined;
let mockIsLoading = false;
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: mockTask, isLoading: mockIsLoading }),
}));
import { A2AContextPane } from "../a2a-context-pane";
describe("A2AContextPane", () => {
it("renders both participants' identity cards linking to /agents/{slug}", () => {
mockTask = undefined;
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend QA")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Backend Dev 1/ })).toHaveAttribute(
"href",
"/agents/be-dev-1",
);
});
it("shows the no-task hint when the conversation has no linked task", () => {
mockTask = undefined;
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId={null} />);
expect(
screen.getByText("This conversation has no linked task"),
).toBeInTheDocument();
});
it("shows the linked task's title, status, and a View task link", () => {
mockTask = buildTask({ title: "Ship the context pane" });
mockIsLoading = false;
render(<A2AContextPane agentA="be-dev-1" agentB="be-qa" taskId="task-1" />);
expect(screen.getByText("Ship the context pane")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /View task/ })).toHaveAttribute(
"href",
"/tasks/task-1",
);
});
});
@@ -30,12 +30,16 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{}}
/>,
);
// Participants via getAgentDisplayName ("{a} <-> {b}").
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
// Both participants get an avatar, matching A2APairCard's PairAvatar.
expect(screen.getByTitle("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByTitle("Backend QA")).toBeInTheDocument();
// Topic, preview, message count, relative timestamp.
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(
@@ -61,6 +65,7 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={onSelect}
isLoading={false}
pulses={{}}
/>,
);
fireEvent.click(screen.getByRole("button"));
@@ -75,6 +80,7 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={onSelect}
isLoading={false}
pulses={{}}
/>,
);
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
@@ -88,8 +94,25 @@ describe("A2AConversationList", () => {
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{}}
/>,
);
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
});
it("flashes a row hot when its pair's pulse key matches (same key as the switchboard)", () => {
render(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{ "be-dev-1|be-qa": 1700000000000 }}
/>,
);
expect(screen.getByTestId("conversation-row")).toHaveAttribute(
"data-pulsing",
"true",
);
});
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { A2AFilterBar } from "../a2a-filter-bar";
import { EMPTY_A2A_FILTERS, type A2AFilters } from "../a2a-filter-utils";
function renderBar(
overrides: Partial<Omit<Parameters<typeof A2AFilterBar>[0], "filters">> & {
filters?: A2AFilters;
} = {},
) {
const onFiltersChange = vi.fn();
const { filters, ...rest } = overrides;
render(
<A2AFilterBar
filters={filters ?? EMPTY_A2A_FILTERS}
onFiltersChange={onFiltersChange}
agentOptions={["be-dev-1", "be-qa"]}
view="list"
{...rest}
/>,
);
return { onFiltersChange };
}
describe("A2AFilterBar", () => {
it("renders a collapsed trigger with no active-count badge by default", () => {
renderBar();
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("shows the active-count badge on the trigger when filters are set", () => {
renderBar({ filters: { ...EMPTY_A2A_FILTERS, agents: ["be-dev-1"] } });
expect(
screen.getByRole("button", { name: "Filters · 1" }),
).toBeInTheDocument();
});
it("opens the popover and renders the Agent checkbox list", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend QA")).toBeInTheDocument();
});
it("fires onFiltersChange when an Agent checkbox is toggled", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Backend Dev 1" }));
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
});
});
it("renders the Task id-fragment input and the No linked task toggle", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByLabelText("Task id fragment")).toBeInTheDocument();
expect(
screen.getByRole("checkbox", { name: "No linked task" }),
).toBeInTheDocument();
});
it("fires onFiltersChange when the task id fragment is typed", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "a");
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
taskIdFragment: "a",
});
});
it("renders Status toggle buttons and two date-range inputs", async () => {
const user = userEvent.setup();
renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(screen.getByRole("button", { name: "Active" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Archived" }),
).toBeInTheDocument();
expect(screen.getByLabelText("From date")).toBeInTheDocument();
expect(screen.getByLabelText("To date")).toBeInTheDocument();
});
it("fires onFiltersChange when a status button is toggled", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("button", { name: "Active" }));
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
statuses: ["active"],
});
});
it("shows an inline note that Task/Status/Date apply to the List view only when view=switchboard", async () => {
const user = userEvent.setup();
renderBar({ view: "switchboard" });
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
expect(
screen.getByText(
"Task, Status, and Date filters apply to the Conversation List view.",
),
).toBeInTheDocument();
});
it("renders one chip per active filter value plus a Clear all action", () => {
renderBar({
filters: {
agents: ["be-dev-1"],
taskIdFragment: "",
noLinkedTask: false,
statuses: ["active"],
dateFrom: "2026-07-01",
dateTo: "",
},
});
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("From 2026-07-01")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Clear all" }),
).toBeInTheDocument();
});
it("removes only the matching filter when a chip's remove button is clicked", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar({
filters: {
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
statuses: ["active"],
},
});
await user.click(
screen.getByRole("button", { name: "Remove Active filter" }),
);
expect(onFiltersChange).toHaveBeenCalledWith({
...EMPTY_A2A_FILTERS,
agents: ["be-dev-1"],
statuses: [],
});
});
it("resets every dimension when Clear all is clicked", async () => {
const user = userEvent.setup();
const { onFiltersChange } = renderBar({
filters: {
agents: ["be-dev-1"],
taskIdFragment: "abc",
noLinkedTask: true,
statuses: ["active"],
dateFrom: "2026-07-01",
dateTo: "2026-07-05",
},
});
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(onFiltersChange).toHaveBeenCalledWith(EMPTY_A2A_FILTERS);
});
it("renders no chip row or Clear all when no filters are active", () => {
renderBar();
expect(
screen.queryByRole("button", { name: "Clear all" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
import {
EMPTY_A2A_FILTERS,
activeA2AFilterCount,
distinctA2AAgents,
filterConversations,
filterPairs,
type A2AFilters,
} from "../a2a-filter-utils";
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: null,
status: "active",
message_count: 3,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: null,
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: null,
last_message_at: null,
message_count: 0,
...overrides,
};
}
function filters(overrides: Partial<A2AFilters> = {}): A2AFilters {
return { ...EMPTY_A2A_FILTERS, ...overrides };
}
describe("filterConversations", () => {
it("passes everything through with no active filters", () => {
const conversations = [
buildConversation({ status: "active" }),
buildConversation({ id: "conv-2", status: "archived" }),
];
expect(filterConversations(conversations, filters())).toHaveLength(2);
});
it("narrows by selected agents (either participant matches)", () => {
const conversations = [
buildConversation({
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
}),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
}),
];
const result = filterConversations(
conversations,
filters({ agents: ["be-qa"] }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("narrows by task id fragment (case-insensitive)", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: "abcdef01-0000" }),
buildConversation({ id: "conv-2", task_id: "ffffffff-0000" }),
];
const result = filterConversations(
conversations,
filters({ taskIdFragment: "ABCDEF" }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("narrows to task_id === null when noLinkedTask is set", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: null }),
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
];
const result = filterConversations(
conversations,
filters({ noLinkedTask: true }),
);
expect(result.map((c) => c.id)).toEqual(["conv-1"]);
});
it("ORs the task fragment and no-linked-task toggle when both are set", () => {
const conversations = [
buildConversation({ id: "conv-1", task_id: null }),
buildConversation({ id: "conv-2", task_id: "abcdef01-0000" }),
buildConversation({ id: "conv-3", task_id: "zzzzzzzz-0000" }),
];
const result = filterConversations(
conversations,
filters({ taskIdFragment: "abcdef", noLinkedTask: true }),
);
expect(result.map((c) => c.id).sort()).toEqual(["conv-1", "conv-2"]);
});
it("narrows by selected statuses", () => {
const conversations = [
buildConversation({ id: "conv-1", status: "active" }),
buildConversation({ id: "conv-2", status: "archived" }),
];
const result = filterConversations(
conversations,
filters({ statuses: ["archived"] }),
);
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
});
it("narrows by date range on last_message_at at day granularity", () => {
const conversations = [
buildConversation({
id: "conv-1",
last_message_at: "2026-07-02T12:00:00Z",
}),
buildConversation({
id: "conv-2",
last_message_at: "2026-07-05T12:00:00Z",
}),
];
const result = filterConversations(
conversations,
filters({ dateFrom: "2026-07-03", dateTo: "2026-07-06" }),
);
expect(result.map((c) => c.id)).toEqual(["conv-2"]);
});
it("falls back to created_at for the date range when last_message_at is null", () => {
const conversations = [
buildConversation({
id: "conv-1",
last_message_at: null,
created_at: "2026-07-02T12:00:00Z",
}),
];
expect(
filterConversations(
conversations,
filters({ dateFrom: "2026-07-02", dateTo: "2026-07-02" }),
),
).toHaveLength(1);
expect(
filterConversations(conversations, filters({ dateFrom: "2026-07-03" })),
).toHaveLength(0);
});
});
describe("filterPairs", () => {
it("passes everything through with no active filters", () => {
const pairs = [
buildPair({ conversation_id: "conv-1" }),
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
];
expect(filterPairs(pairs, filters())).toHaveLength(2);
});
it("narrows by selected agents only — Task/Status/Date never apply", () => {
const pairs = [
buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" }),
buildPair({ agent_a: "auditor", agent_b: "product-owner" }),
];
const result = filterPairs(
pairs,
filters({
agents: ["be-dev-1"],
statuses: ["archived"],
dateFrom: "2099-01-01",
}),
);
expect(result).toHaveLength(1);
expect(result[0].agent_a).toBe("be-dev-1");
});
});
describe("distinctA2AAgents", () => {
it("dedupes and sorts agent slugs across conversations and pairs", () => {
const conversations = [
buildConversation({ agent_a: "fe-qa", agent_b: "be-qa" }),
];
const pairs = [buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" })];
expect(distinctA2AAgents(conversations, pairs)).toEqual([
"be-dev-1",
"be-qa",
"fe-qa",
]);
});
});
describe("activeA2AFilterCount", () => {
it("counts zero for the empty filter state", () => {
expect(activeA2AFilterCount(EMPTY_A2A_FILTERS)).toBe(0);
});
it("counts one entry per active chip", () => {
expect(
activeA2AFilterCount(
filters({
agents: ["be-dev-1", "be-qa"],
statuses: ["active"],
dateFrom: "2026-07-01",
}),
),
).toBe(4);
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard } from "../a2a-pair-card";
import { A2APairCard, PairAvatar } from "../a2a-pair-card";
function buildPair(
overrides: Partial<AdminPairSummary> = {},
@@ -51,6 +51,13 @@ describe("A2APairCard", () => {
expect(screen.queryByText("5")).not.toBeInTheDocument();
});
it("colors each avatar by team, not a per-agent hue", () => {
render(<PairAvatar slug="fe-dev-1" />);
expect(screen.getByTitle("Frontend Dev 1")).toHaveClass(
"border-violet-500/40",
);
});
it("marks the card as selected via aria-pressed", () => {
render(
<A2APairCard
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { A2AChatMessage } from "@/lib/api/a2a";
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
@@ -75,4 +75,100 @@ describe("A2ATranscript", () => {
screen.getByText(/No messages in this conversation yet/),
).toBeInTheDocument();
});
it("shows a distinct nothing-selected hint when hasSelection is false", () => {
render(
<A2ATranscript messages={[]} isLoading={false} hasSelection={false} />,
);
expect(
screen.getByText("Select a conversation to view messages"),
).toBeInTheDocument();
expect(
screen.queryByText(/No messages in this conversation yet/),
).not.toBeInTheDocument();
});
it("shows the scoped error state with a working Retry button", () => {
const onRetry = vi.fn();
render(
<A2ATranscript messages={[]} isLoading={false} error onRetry={onRetry} />,
);
expect(
screen.getByText("Couldn't load this conversation"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("error state takes precedence over the empty/nothing-selected states", () => {
render(<A2ATranscript messages={[]} isLoading={false} error />);
expect(
screen.queryByText(/No messages in this conversation yet/),
).not.toBeInTheDocument();
});
});
describe("A2ATranscript new-row entrance (frame -> row fades in, then settles)", () => {
// Deterministic rAF, same idiom as A2APairCard's pulse test.
let rafCallback: FrameRequestCallback | null = null;
beforeEach(() => {
rafCallback = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
rafCallback = cb;
return 1;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders initially-loaded messages settled (never marked new)", () => {
render(
<A2ATranscript
messages={[buildMessage({ id: "m1" })]}
isLoading={false}
/>,
);
const rows = screen.getAllByTestId("transcript-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveAttribute("data-new", "false");
});
it("marks a message arriving after initial load as new, then settles it next frame", () => {
const { rerender } = render(
<A2ATranscript
messages={[buildMessage({ id: "m1" })]}
isLoading={false}
/>,
);
rerender(
<A2ATranscript
messages={[
buildMessage({ id: "m1" }),
buildMessage({
id: "m2",
content: "second",
created_at: "2026-07-02T10:05:00Z",
}),
]}
isLoading={false}
/>,
);
const rows = screen.getAllByTestId("transcript-row");
const newRow = rows.find((r) => r.textContent?.includes("second"));
expect(newRow).toHaveAttribute("data-new", "true");
act(() => {
rafCallback?.(0);
});
const settledRows = screen.getAllByTestId("transcript-row");
for (const row of settledRows) {
expect(row).toHaveAttribute("data-new", "false");
}
});
});
@@ -1,9 +1,12 @@
import { describe, it, expect } from "vitest";
import {
connectionDotClasses,
connectionStateLabel,
lastSenderOf,
pickDefaultRecipient,
recipientOptions,
} from "../a2a-utils";
import type { ConnectionState } from "@/lib/websocket/connection";
describe("lastSenderOf", () => {
it("returns null for an empty transcript", () => {
@@ -52,3 +55,37 @@ describe("recipientOptions", () => {
expect(recipientOptions("be-dev-1", "ceo")).toEqual(["be-dev-1"]);
});
});
describe("connectionStateLabel (design doc §3 — all four states distinct)", () => {
it.each([
["connected", "Live"],
["connecting", "Connecting…"],
["reconnecting", "Reconnecting…"],
["disconnected", "Offline"],
] satisfies [ConnectionState, string][])(
"labels %s as %s",
(state, label) => {
expect(connectionStateLabel(state)).toBe(label);
},
);
});
describe("connectionDotClasses", () => {
it("connected is static — no pulse", () => {
expect(connectionDotClasses("connected")).not.toContain("animate-pulse");
});
it("connecting and reconnecting pulse with a motion-reduce guard", () => {
for (const state of ["connecting", "reconnecting"] as ConnectionState[]) {
const classes = connectionDotClasses(state);
expect(classes).toContain("animate-pulse");
expect(classes).toContain("motion-reduce:animate-none");
}
});
it("disconnected is static and muted", () => {
const classes = connectionDotClasses("disconnected");
expect(classes).not.toContain("animate-pulse");
expect(classes).toContain("muted-foreground");
});
});
@@ -0,0 +1,67 @@
"use client";
import { Loader2, WifiOff, X } from "lucide-react";
import type { ConnectionState } from "@/lib/websocket/connection";
import { cn } from "@/lib/utils";
import { connectionDotClasses, connectionStateLabel } from "./a2a-utils";
/** Pane-header connection indicator: dot + label, plus a spinner/offline icon
* for the connecting/reconnecting/disconnected states (design doc §3). All
* four `ConnectionState` values render distinctly — a live-but-quiet
* conversation must read differently from a stream that is the problem. */
export function A2AConnectionBadge({ state }: { state: ConnectionState }) {
return (
<div className="flex items-center gap-1.5">
<span
className={cn("h-2 w-2 rounded-full", connectionDotClasses(state))}
/>
<span className="text-xs text-muted-foreground">
{connectionStateLabel(state)}
</span>
{(state === "connecting" || state === "reconnecting") && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{state === "disconnected" && (
<WifiOff className="h-3 w-3 text-muted-foreground" />
)}
</div>
);
}
interface A2AConnectionBannerProps {
state: "reconnecting" | "disconnected";
onDismiss: () => void;
}
/** Dismissable strip above the stream pane's message list — a scoped
* live-connection hint, not the full-page `OfflineState` (design doc §3). */
export function A2AConnectionBanner({
state,
onDismiss,
}: A2AConnectionBannerProps) {
const isDisconnected = state === "disconnected";
return (
<div
className={cn(
"flex items-center justify-between gap-2 border-b text-xs px-3 py-1.5",
isDisconnected
? "bg-destructive/10 border-destructive/30 text-destructive"
: "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400",
)}
>
<span>
{isDisconnected
? "Disconnected — reconnecting automatically"
: "Reconnecting — messages may be out of date"}
</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import { useTask } from "@/hooks/use-tasks";
import { cn } from "@/lib/utils";
import { ListTodo, Users } from "lucide-react";
/** One participant's identity card — avatar, name, team badge — linking to
* the agent's own detail page (design doc §1/§2). Read-only, same team-color
* mapping every other identity affordance uses. */
function IdentityCard({ slug }: { slug: string }) {
const teamColor = getAgentTeamColor(slug);
return (
<Link
href={`/agents/${slug}`}
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
>
<div
className={cn(
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[teamColor],
)}
title={getAgentDisplayName(slug)}
>
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(slug)}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{getAgentDisplayName(slug)}
</div>
<Badge
variant="outline"
className={cn("text-[10px] mt-0.5", TEAM_COLOR_CLASSES[teamColor])}
>
{teamColor.replace("_", "/")}
</Badge>
</div>
</Link>
);
}
interface A2AContextPaneProps {
agentA: string;
agentB: string;
/** null when this conversation (or peeked pair) has no linked task. */
taskId: string | null;
}
/** The `xl:`+ context region: both participants' identity cards, a linked-task
* summary, and a no-task hint when there isn't one — read-only, never a
* second place to act on the conversation (design doc §1). */
export function A2AContextPane({
agentA,
agentB,
taskId,
}: A2AContextPaneProps) {
const { data: task, isLoading } = useTask(taskId ?? "");
return (
<div className="p-3 space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Context</span>
</div>
<div className="space-y-2">
<IdentityCard slug={agentA} />
<IdentityCard slug={agentB} />
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Linked task
</div>
{!taskId ? (
<p className="text-xs text-muted-foreground">
This conversation has no linked task
</p>
) : isLoading || !task ? (
<Skeleton className="h-16 w-full" />
) : (
<div className="rounded-lg border p-2.5 space-y-1.5">
<div className="text-sm font-medium truncate">{task.title}</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant={task.status === "completed" ? "default" : "secondary"}
className="text-xs"
>
{task.status}
</Badge>
<Link
prefetch={false}
href={`/tasks/${taskId}`}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ListTodo className="h-3 w-3" />
View task
</Link>
</div>
</div>
)}
</div>
</div>
);
}
@@ -6,14 +6,121 @@ 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 { usePulseFlash } from "@/hooks/use-pulse-flash";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { ListTodo, MessagesSquare } from "lucide-react";
import { PairAvatar } from "./a2a-pair-card";
import { PAIR_PULSE_FADE_MS, pairKey } from "./a2a-switchboard-utils";
interface A2AConversationListProps {
conversations: AdminConversationSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
/** pairKey(agent_a, agent_b) -> epoch ms of the latest matching frame —
* the same map the switchboard uses, so a row flashes on the same live
* pulse as its pair's card. */
pulses: Record<string, number>;
}
interface ConversationRowProps {
conversation: AdminConversationSummary;
isSelected: boolean;
onSelect: (id: string) => void;
pulsedAt: number | null;
}
function ConversationRow({
conversation,
isSelected,
onSelect,
pulsedAt,
}: ConversationRowProps) {
const isPulsing = usePulseFlash(pulsedAt);
return (
<div
role="button"
tabIndex={0}
data-testid="conversation-row"
data-pulsing={isPulsing}
onClick={() => onSelect(conversation.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(conversation.id);
}
}}
className={cn(
"block w-full cursor-pointer p-3 rounded-lg border",
"transition-[background-color,box-shadow] ease-out",
isSelected ? "border-primary" : "border-border",
isPulsing
? "bg-emerald-500/15 shadow-[0_0_0_1px_rgba(16,185,129,0.6)]"
: isSelected
? "bg-primary/10"
: "bg-card hover:bg-muted/50 hover:border-primary/50",
)}
style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 min-w-0 flex-1">
<div className="flex -space-x-2 shrink-0 pt-0.5">
<PairAvatar slug={conversation.agent_a} />
<PairAvatar slug={conversation.agent_b} />
</div>
<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>
<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>
);
}
export function A2AConversationList({
@@ -21,6 +128,7 @@ export function A2AConversationList({
selectedId,
onSelect,
isLoading,
pulses,
}: A2AConversationListProps) {
if (isLoading) {
return (
@@ -47,76 +155,16 @@ export function A2AConversationList({
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{conversations.map((conversation) => (
<div
<ConversationRow
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")
conversation={conversation}
isSelected={selectedId === conversation.id}
onSelect={onSelect}
pulsedAt={
pulses[pairKey(conversation.agent_a, conversation.agent_b)] ??
null
}
>
<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>
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SlidersHorizontal, X } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import {
EMPTY_A2A_FILTERS,
activeA2AFilterCount,
type A2AConversationStatus,
type A2AFilters,
} from "./a2a-filter-utils";
const STATUS_OPTIONS: { value: A2AConversationStatus; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
];
interface A2AFilterBarProps {
filters: A2AFilters;
onFiltersChange: (filters: A2AFilters) => void;
/** Distinct agent slugs to list as checkboxes (already deduped+sorted —
* see `distinctA2AAgents`). */
agentOptions: string[];
/** Task/Status/Date only narrow the List view — Switchboard pairs have no
* conversation to filter those dimensions on (design doc §1). */
view: "switchboard" | "list";
}
interface FilterChip {
key: string;
label: string;
onRemove: () => void;
}
/**
* Popover-triggered filter panel above the switchboard/list content: Agent
* (multi-select), Task (id fragment + "No linked task"), Status (toggle
* buttons) and a date range — plus the active-filter chip row and
* Clear-all. See docs/ux_ui/design/conversations-filter-control.md.
*/
export function A2AFilterBar({
filters,
onFiltersChange,
agentOptions,
view,
}: A2AFilterBarProps) {
const toggleAgent = (agent: string) => {
onFiltersChange({
...filters,
agents: filters.agents.includes(agent)
? filters.agents.filter((a) => a !== agent)
: [...filters.agents, agent],
});
};
const toggleStatus = (status: A2AConversationStatus) => {
onFiltersChange({
...filters,
statuses: filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status],
});
};
const clearAll = () => onFiltersChange(EMPTY_A2A_FILTERS);
const chips: FilterChip[] = [
...filters.agents.map((agent) => ({
key: `agent-${agent}`,
label: getAgentDisplayName(agent),
onRemove: () => toggleAgent(agent),
})),
...(filters.taskIdFragment
? [
{
key: "task-fragment",
label: `Task: ${filters.taskIdFragment}`,
onRemove: () => onFiltersChange({ ...filters, taskIdFragment: "" }),
},
]
: []),
...(filters.noLinkedTask
? [
{
key: "no-linked-task",
label: "No linked task",
onRemove: () =>
onFiltersChange({ ...filters, noLinkedTask: false }),
},
]
: []),
...filters.statuses.map((status) => ({
key: `status-${status}`,
label: STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status,
onRemove: () => toggleStatus(status),
})),
...(filters.dateFrom
? [
{
key: "date-from",
label: `From ${filters.dateFrom}`,
onRemove: () => onFiltersChange({ ...filters, dateFrom: "" }),
},
]
: []),
...(filters.dateTo
? [
{
key: "date-to",
label: `To ${filters.dateTo}`,
onRemove: () => onFiltersChange({ ...filters, dateTo: "" }),
},
]
: []),
];
const count = activeA2AFilterCount(filters);
return (
<div className="mb-2 shrink-0">
<div className="flex items-center justify-end">
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
{count > 0 ? `Filters · ${count}` : "Filters"}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 max-h-[70vh] space-y-3 overflow-y-auto"
>
{view === "switchboard" && (
<p className="text-xs text-muted-foreground">
Task, Status, and Date filters apply to the Conversation List
view.
</p>
)}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">Agent</span>
{filters.agents.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => onFiltersChange({ ...filters, agents: [] })}
>
Clear
</Button>
)}
</div>
<div className="max-h-40 space-y-1 overflow-y-auto">
{agentOptions.map((agent) => (
<label
key={agent}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-muted"
>
<Checkbox
checked={filters.agents.includes(agent)}
onCheckedChange={() => toggleAgent(agent)}
/>
<span className="text-sm">
{getAgentDisplayName(agent)}
</span>
</label>
))}
</div>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Task</span>
<Input
value={filters.taskIdFragment}
onChange={(e) =>
onFiltersChange({
...filters,
taskIdFragment: e.target.value,
})
}
placeholder="Task id fragment..."
className="mt-1 h-7 text-xs"
aria-label="Task id fragment"
/>
<label className="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox
checked={filters.noLinkedTask}
onCheckedChange={(checked) =>
onFiltersChange({
...filters,
noLinkedTask: checked === true,
})
}
/>
<span className="text-sm">No linked task</span>
</label>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Status</span>
<div className="mt-1 flex items-center gap-1">
{STATUS_OPTIONS.map((opt) => (
<Button
key={opt.value}
type="button"
variant={
filters.statuses.includes(opt.value)
? "secondary"
: "outline"
}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={filters.statuses.includes(opt.value)}
onClick={() => toggleStatus(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="border-t pt-2">
<span className="text-sm font-medium">Date range</span>
<div className="mt-1 flex items-center gap-2">
<Input
type="date"
value={filters.dateFrom}
onChange={(e) =>
onFiltersChange({ ...filters, dateFrom: e.target.value })
}
className="h-7 text-xs"
aria-label="From date"
/>
<Input
type="date"
value={filters.dateTo}
onChange={(e) =>
onFiltersChange({ ...filters, dateTo: e.target.value })
}
className="h-7 text-xs"
aria-label="To date"
/>
</div>
</div>
{count > 0 && (
<div className="flex justify-end border-t pt-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={clearAll}
>
Clear all
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
{chips.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{chips.map((chip) => (
<Badge key={chip.key} variant="secondary" className="gap-1">
{chip.label}
<button
type="button"
aria-label={`Remove ${chip.label} filter`}
onClick={chip.onRemove}
>
<X className="h-3 w-3 cursor-pointer hover:text-destructive" />
</button>
</Badge>
))}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearAll}
>
Clear all
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,166 @@
/**
* Pure filter helpers for the A2A page's filter control — shared by both the
* switchboard (pairs) and the classic list (conversations) so the same
* filter state narrows both views (per the per-view rules in
* docs/ux_ui/design/conversations-filter-control.md §1).
*
* All four dimensions filter client-side over the already-fetched page
* (there are no backend query params for them yet — see the design doc's
* "Future work" note).
*/
import type { AdminConversationSummary, AdminPairSummary } from "@/lib/api/a2a";
/** The two known `AdminConversationSummary.status` values. */
export type A2AConversationStatus = "active" | "archived";
export interface A2AFilters {
/** Selected agent slugs — a conversation/pair matches if either
* participant is selected (empty = no agent filter). */
agents: string[];
/** Free-text fragment matched against `task_id` (case-insensitive). */
taskIdFragment: string;
/** When true, also match conversations with `task_id === null`. */
noLinkedTask: boolean;
/** Selected statuses (empty = no status filter). List-view only. */
statuses: A2AConversationStatus[];
/** Inclusive lower bound, `YYYY-MM-DD` (native `<input type="date">`
* value) or `""` for unset. List-view only. */
dateFrom: string;
/** Inclusive upper bound, `YYYY-MM-DD` or `""` for unset. List-view
* only. */
dateTo: string;
}
export const EMPTY_A2A_FILTERS: A2AFilters = {
agents: [],
taskIdFragment: "",
noLinkedTask: false,
statuses: [],
dateFrom: "",
dateTo: "",
};
/** Total count of active filter values, one per chip — drives the
* trigger's `Filters · N` badge. */
export function activeA2AFilterCount(filters: A2AFilters): number {
return (
filters.agents.length +
(filters.taskIdFragment.trim() ? 1 : 0) +
(filters.noLinkedTask ? 1 : 0) +
filters.statuses.length +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0)
);
}
function matchesAgent(
agentA: string,
agentB: string,
agents: ReadonlyArray<string>,
): boolean {
if (agents.length === 0) return true;
return agents.includes(agentA) || agents.includes(agentB);
}
function matchesTask(
taskId: string | null,
fragment: string,
noLinkedTask: boolean,
): boolean {
const frag = fragment.trim().toLowerCase();
if (!frag && !noLinkedTask) return true;
const fragmentMatch = frag
? !!taskId && taskId.toLowerCase().includes(frag)
: false;
const noLinkedMatch = noLinkedTask ? taskId === null : false;
return fragmentMatch || noLinkedMatch;
}
function matchesStatus(
status: string,
statuses: ReadonlyArray<A2AConversationStatus>,
): boolean {
if (statuses.length === 0) return true;
return statuses.includes(status as A2AConversationStatus);
}
/** Day-granularity local-timezone `YYYY-MM-DD`, comparable against a native
* `<input type="date">` value. */
function localDateOnly(iso: string): string {
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function matchesDateRange(
timestamp: string | null,
dateFrom: string,
dateTo: string,
): boolean {
if (!dateFrom && !dateTo) return true;
if (!timestamp) return false;
const day = localDateOnly(timestamp);
if (dateFrom && day < dateFrom) return false;
if (dateTo && day > dateTo) return false;
return true;
}
/** Narrow conversations by all four dimensions — the classic List view. */
export function filterConversations(
conversations: ReadonlyArray<AdminConversationSummary>,
filters: A2AFilters,
): AdminConversationSummary[] {
return conversations.filter(
(conversation) =>
matchesAgent(
conversation.agent_a,
conversation.agent_b,
filters.agents,
) &&
matchesTask(
conversation.task_id,
filters.taskIdFragment,
filters.noLinkedTask,
) &&
matchesStatus(conversation.status, filters.statuses) &&
matchesDateRange(
conversation.last_message_at ?? conversation.created_at,
filters.dateFrom,
filters.dateTo,
),
);
}
/** Narrow switchboard pairs — Agent only (design doc §1 "Per-view
* applicability"): a pair with no conversation has no task/status/date to
* filter on. */
export function filterPairs(
pairs: ReadonlyArray<AdminPairSummary>,
filters: A2AFilters,
): AdminPairSummary[] {
return pairs.filter((pair) =>
matchesAgent(pair.agent_a, pair.agent_b, filters.agents),
);
}
/** Distinct agent slugs present across the currently loaded pairs +
* conversations, deduplicated and sorted — the Agent checkbox list's
* option set (design doc §1, dimension 1). */
export function distinctA2AAgents(
conversations: ReadonlyArray<AdminConversationSummary>,
pairs: ReadonlyArray<AdminPairSummary>,
): string[] {
const slugs = new Set<string>();
for (const pair of pairs) {
slugs.add(pair.agent_a);
slugs.add(pair.agent_b);
}
for (const conversation of conversations) {
slugs.add(conversation.agent_a);
slugs.add(conversation.agent_b);
}
return Array.from(slugs).sort();
}
+16 -29
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { cn } from "@/lib/utils";
import { usePulseFlash } from "@/hooks/use-pulse-flash";
import { formatDistanceToNow } from "date-fns";
import { PAIR_PULSE_FADE_MS } from "./a2a-switchboard-utils";
@@ -17,10 +22,16 @@ interface A2APairCardProps {
onOpen: () => void;
}
function PairAvatar({ slug }: { slug: string }) {
/** One agent's avatar (initials in a circle) — shared with
* A2AConversationList so a pair/conversation's two participants render
* identically across the switchboard and the classic list. */
export function PairAvatar({ slug }: { slug: string }) {
return (
<div
className="h-7 w-7 rounded-full bg-primary/10 border flex items-center justify-center shrink-0"
className={cn(
"h-7 w-7 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[getAgentTeamColor(slug)],
)}
title={getAgentDisplayName(slug)}
>
<span className="text-[9px] font-bold tracking-tight">
@@ -44,31 +55,7 @@ export function A2APairCard({
onOpen,
}: A2APairCardProps) {
const hasHistory = pair.conversation_id !== null;
const [isPulsing, setIsPulsing] = useState(false);
// Render-phase derivation, not an Effect (react.dev/learn/you-might-not-
// need-an-effect#adjusting-some-state-when-a-prop-changes): flash hot in
// the very same render that receives a new pulsedAt, comparing against the
// last value we've seen. No cascading extra render from an Effect body.
// Seeded to null (not the initial pulsedAt) so a card that *mounts*
// already carrying a live pulse — e.g. switching into switchboard view
// right after a frame arrived — still flashes hot instead of looking cold.
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
if (pulsedAt !== lastSeenPulse) {
setLastSeenPulse(pulsedAt);
if (pulsedAt !== null) setIsPulsing(true);
}
// Flip back on the next paint frame — the long CSS transition-duration
// below then animates the decay from "hot" to baseline over
// PAIR_PULSE_FADE_MS. The setState here is inside the (async) rAF
// callback, not the Effect body itself, so it's the intended "subscribe to
// an external clock" use of an Effect.
useEffect(() => {
if (!isPulsing) return;
const raf = requestAnimationFrame(() => setIsPulsing(false));
return () => cancelAnimationFrame(raf);
}, [isPulsing]);
const isPulsing = usePulseFlash(pulsedAt);
return (
<button
+203 -37
View File
@@ -1,28 +1,111 @@
"use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Markdown } from "@/components/ui/markdown";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
getAgentDisplayName,
getAgentInitials,
getAgentTeamColor,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import { cn } from "@/lib/utils";
import type { A2AChatMessage } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns";
import { MessagesSquare } from "lucide-react";
import { AlertTriangle, MessagesSquare } from "lucide-react";
interface A2ATranscriptProps {
messages: A2AChatMessage[];
isLoading: boolean;
/** False when no conversation/pair is selected at all — distinguishes
* "nothing to show yet" from "this conversation genuinely has no
* messages" (design doc §5). Defaults true (existing callers). */
hasSelection?: boolean;
/** True when the messages fetch itself failed — a scoped retry, not the
* page-level OfflineState (design doc §5). */
error?: boolean;
onRetry?: () => void;
}
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
/** How close to the bottom (px) still counts as "at the bottom" for the
* auto-scroll / new-messages-pill decision. */
const BOTTOM_THRESHOLD_PX = 48;
function EmptyState({
icon: Icon,
message,
}: {
icon: typeof MessagesSquare;
message: string;
}) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
export function A2ATranscript({
messages,
isLoading,
hasSelection = true,
error = false,
onRetry,
}: A2ATranscriptProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
const [seenIds, setSeenIds] = useState<Set<string> | null>(null);
const [newRowIds, setNewRowIds] = useState<ReadonlySet<string>>(new Set());
const [showJumpPill, setShowJumpPill] = useState(false);
const [pillEntering, setPillEntering] = useState(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(),
);
const currentIds = sorted.map((m) => m.id);
// Render-phase derivation (same idiom as A2APairCard's usePulseFlash — state,
// not a ref, compared against the current props): the first time a batch of
// ids appears it seeds "seen" without flagging anything new — messages
// present at initial load must render settled, never animate in. A later id
// absent from "seen" is a genuine arrival.
if (seenIds === null) {
setSeenIds(new Set(currentIds));
} else {
const freshIds = currentIds.filter((id) => !seenIds.has(id));
if (freshIds.length > 0) {
setSeenIds(new Set([...seenIds, ...freshIds]));
if (isAtBottom) {
setNewRowIds((prev) => new Set([...prev, ...freshIds]));
} else {
// Scrolled up: the new row is off-screen — surface the "New
// messages" pill instead of an invisible entrance transition.
setShowJumpPill(true);
setPillEntering(true);
}
}
}
// Settle the entrance transition one paint frame after new rows appear.
useEffect(() => {
if (newRowIds.size === 0) return;
const raf = requestAnimationFrame(() => setNewRowIds(new Set()));
return () => cancelAnimationFrame(raf);
}, [newRowIds]);
useEffect(() => {
if (!pillEntering) return;
const raf = requestAnimationFrame(() => setPillEntering(false));
return () => cancelAnimationFrame(raf);
}, [pillEntering]);
// Auto-scroll to bottom only once on initial load.
useEffect(() => {
@@ -32,6 +115,23 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
}
}, [sorted.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD_PX;
setIsAtBottom(atBottom);
if (atBottom) setShowJumpPill(false);
}, []);
const scrollToBottom = useCallback(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
setShowJumpPill(false);
}, []);
if (isLoading) {
return (
<div className="p-4 space-y-4">
@@ -48,51 +148,117 @@ export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
);
}
if (sorted.length === 0) {
if (error) {
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>
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50 text-destructive" />
<p className="text-sm mb-3">Couldn&apos;t load this conversation</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
</div>
);
}
if (!hasSelection) {
return (
<EmptyState
icon={MessagesSquare}
message="Select a conversation to view messages"
/>
);
}
if (sorted.length === 0) {
return (
<EmptyState
icon={MessagesSquare}
message="No messages in this conversation yet"
/>
);
}
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>
<div className="relative h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-4"
>
<div className="space-y-3">
{sorted.map((message) => {
const isNew = newRowIds.has(message.id);
const teamColor = getAgentTeamColor(message.from_agent);
return (
<div
key={message.id}
data-testid="transcript-row"
data-new={isNew}
className={cn(
"flex gap-3 p-3 rounded-lg border hover:bg-muted/30",
// ponytail: one shared 200ms transition covers
// opacity/transform/background so the reduced-motion
// background flash rides the same timing as the
// full-motion fade-in instead of a second bespoke duration.
"transition-[opacity,transform,background-color] duration-200 ease-out",
"motion-reduce:transition-colors motion-reduce:translate-y-0",
isNew
? "opacity-0 translate-y-1 bg-muted/50 motion-reduce:opacity-100"
: "opacity-100 translate-y-0 bg-card",
)}
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.created_at))} ago
</span>
>
<div
className={cn(
"h-9 w-10 rounded-lg flex items-center justify-center shrink-0 border",
TEAM_COLOR_CLASSES[teamColor],
)}
>
<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 className="text-sm prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
{showJumpPill && (
<button
type="button"
onClick={scrollToBottom}
className={cn(
"absolute bottom-3 left-1/2 rounded-full bg-primary text-primary-foreground text-xs px-3 py-1 shadow-md",
"transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
pillEntering
? "opacity-0 translate-x-[-50%] translate-y-1 motion-reduce:opacity-100 motion-reduce:translate-y-0"
: "opacity-100 translate-x-[-50%] translate-y-0",
)}
>
New messages
</button>
)}
</div>
);
}
+30
View File
@@ -3,6 +3,7 @@
*/
import type { A2AChatMessage } from "@/lib/api/a2a";
import type { ConnectionState } from "@/lib/websocket/connection";
/** The human CEO's fixed slug — never a valid reply target (the CEO composes
* as itself, so it can't be its own recipient). */
@@ -47,3 +48,32 @@ export function pickDefaultRecipient(
if (lastSender === agentA || lastSender === agentB) return lastSender;
return agentA;
}
/** Label for the pane-header connection badge (design doc §3). */
export function connectionStateLabel(state: ConnectionState): string {
switch (state) {
case "connected":
return "Live";
case "connecting":
return "Connecting…";
case "reconnecting":
return "Reconnecting…";
case "disconnected":
return "Offline";
}
}
/** Dot color for the pane-header connection badge — `connected` is static
* (no pulse); `connecting`/`reconnecting` share the amber pulsing family,
* guarded against `prefers-reduced-motion` (design doc §3). */
export function connectionDotClasses(state: ConnectionState): string {
switch (state) {
case "connected":
return "bg-emerald-500";
case "connecting":
case "reconnecting":
return "bg-amber-500 animate-pulse motion-reduce:animate-none";
case "disconnected":
return "bg-muted-foreground/40";
}
}
@@ -91,9 +91,9 @@ describe("pipelineStageLabel", () => {
expect(
pipelineStageLabel({ kind: "rendering", attempt: 2, maxAttempts: 5 }),
).toBe("Rendering (attempt 2/5)");
expect(
pipelineStageLabel({ kind: "render_failed", reason: "boom" }),
).toBe("Render failed: boom");
expect(pipelineStageLabel({ kind: "render_failed", reason: "boom" })).toBe(
"Render failed: boom",
);
expect(pipelineStageLabel({ kind: "render_failed", reason: null })).toBe(
"Render failed",
);
@@ -150,10 +150,12 @@ export function SocialHistorySection({ className }: { className?: string }) {
const isLoading = open && (xLoading || videoLoading);
const rows: UnifiedRow[] = [
...(xHistory ?? []).map((entry): UnifiedRow => ({ kind: "x", entry })),
...(videoHistory ?? []).map((entry): UnifiedRow => ({
kind: "video",
entry,
})),
...(videoHistory ?? []).map(
(entry): UnifiedRow => ({
kind: "video",
entry,
}),
),
].sort(
(a, b) =>
new Date(b.entry.acted_at).getTime() -
@@ -32,7 +32,11 @@ const IN_REVIEW_STATUSES = new Set([
export function derivePipelineStage(
item: Pick<
VideoPipelineItem,
"status" | "render_status" | "render_attempts" | "max_attempts" | "render_error"
| "status"
| "render_status"
| "render_attempts"
| "max_attempts"
| "render_error"
>,
): PipelineStage {
if (item.status === "completed") {
@@ -46,7 +50,8 @@ export function derivePipelineStage(
maxAttempts: item.max_attempts,
};
}
if (item.status === "awaiting_ceo_approval") return { kind: "awaiting_approval" };
if (item.status === "awaiting_ceo_approval")
return { kind: "awaiting_approval" };
if (IN_REVIEW_STATUSES.has(item.status)) return { kind: "in_review" };
return { kind: "authoring" };
}