mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user