feat(canvas): Agent Comms grouped by peer with sub-tabs

The chronological-only view was a noodle once Director + N peers
exchange more than a few rounds. New layout: a sub-tab bar at the
top of the panel, with "All" pinned leftmost and one tab per peer
(name + count). Selecting a peer filters the thread to that one
DD↔X conversation; "All" preserves the previous chronological view
as the default.

Tab ordering follows Slack/Linear DM-list convention: most-recent
activity descending, so active conversations rise to the top
without the user scrolling. Counts in parens match Slack's unread
hint pattern (no separate read/unread state — the count is total
in this conversation, computed from the same in-memory message
list the panel already maintains).

Pure-helper extraction: peer-summary derivation lives in
`buildPeerSummary(messages)` so the sort + count logic is unit-
testable without rendering the panel. 5 new tests cover: count
aggregation, most-recent-first ordering, lastTs as max-not-last,
empty input, name-stability when the same peerId carries different
names across messages.

Keyboard: ArrowLeft/Right cycle peer tabs (matches the existing
My Chat / Agent Comms tab pattern in ChatTab). Auto-prune: if the
selected peer has zero messages after a setMessages update (rare,
e.g. dedupe drops the last bubble), fall back to "All" so the
viewer doesn't see an empty thread.

Frontend-only — no platform / runtime / DB changes. The existing
`peerId` / `peerName` fields on CommMessage already carry every
piece of data the new UI needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-26 20:16:11 -07:00
parent 0027322699
commit 5f08455340
2 changed files with 257 additions and 11 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
@ -328,20 +328,190 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
);
}
return <GroupedCommsView messages={messages} bottomRef={bottomRef} />;
}
// ALL_PEERS is the sentinel selectedPeerId value for "show every peer
// in one chronological feed" — the panel's pre-grouping default.
// Picked to be a value no real peerId can collide with (workspace IDs
// are UUIDs).
export const ALL_PEERS = "__all__";
/** PeerSummary is one entry in the sub-tab bar the per-peer
* message count + most-recent timestamp used for ordering. Exported
* so the sort/count behaviour can be unit-tested without React. */
export interface PeerSummary {
peerId: string;
peerName: string;
count: number;
lastTs: string;
}
/** buildPeerSummary collapses the flat message list into per-peer
* rows, sorted by most-recent activity descending. Order matches
* Slack/Linear's DM list active conversations rise to the top.
* Pure function so the sort + count behaviour is testable without
* rendering the panel. */
export function buildPeerSummary(messages: CommMessage[]): PeerSummary[] {
const acc = new Map<string, PeerSummary>();
for (const m of messages) {
const existing = acc.get(m.peerId);
if (existing) {
existing.count += 1;
if (m.timestamp > existing.lastTs) existing.lastTs = m.timestamp;
} else {
acc.set(m.peerId, {
peerId: m.peerId,
peerName: m.peerName,
count: 1,
lastTs: m.timestamp,
});
}
}
return Array.from(acc.values()).sort((a, b) => (a.lastTs < b.lastTs ? 1 : -1));
}
/** GroupedCommsView renders the messages list with a peer-keyed
* sub-tab bar at the top so the user can drill into one DDX thread
* at a time instead of reading a single chronological mix.
*
* Tab list derivation: walk the messages once, count per-peer, sort
* by most-recent timestamp DESC so the active conversations rise to
* the top. "All" stays pinned as the leftmost tab. */
function GroupedCommsView({
messages,
bottomRef,
}: {
messages: CommMessage[];
bottomRef: React.RefObject<HTMLDivElement | null>;
}) {
const [selectedPeerId, setSelectedPeerId] = useState<string>(ALL_PEERS);
// Build per-peer summary: count + most-recent timestamp + display
// name. One pass over messages — O(n). Logic lives in a pure
// helper so it's unit-testable without rendering the panel.
const peerSummary = useMemo(() => buildPeerSummary(messages), [messages]);
// Auto-prune: if the user had selected a peer and that peer no
// longer has messages (rare — only happens if dedupe removes the
// last bubble for them), fall back to "All" rather than rendering
// an empty thread.
useEffect(() => {
if (selectedPeerId === ALL_PEERS) return;
if (!peerSummary.some((p) => p.peerId === selectedPeerId)) {
setSelectedPeerId(ALL_PEERS);
}
}, [peerSummary, selectedPeerId]);
const visible = useMemo(() => {
if (selectedPeerId === ALL_PEERS) return messages;
return messages.filter((m) => m.peerId === selectedPeerId);
}, [messages, selectedPeerId]);
return (
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{messages.map((msg) =>
msg.status === "error" ? (
<ErrorMessage key={msg.id} msg={msg} />
) : (
<NormalMessage key={msg.id} msg={msg} />
),
)}
<div ref={bottomRef} />
<div className="flex flex-col h-full min-h-0">
<PeerTabs
peers={peerSummary}
totalCount={messages.length}
selectedPeerId={selectedPeerId}
onSelect={setSelectedPeerId}
/>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{visible.map((msg) =>
msg.status === "error" ? (
<ErrorMessage key={msg.id} msg={msg} />
) : (
<NormalMessage key={msg.id} msg={msg} />
),
)}
<div ref={bottomRef} />
</div>
</div>
);
}
/** PeerTabs renders the horizontally-scrolling sub-tab bar.
* Keyboard: ArrowLeft / ArrowRight cycle peers (matches the existing
* My Chat / Agent Comms tab pattern in ChatTab). */
function PeerTabs({
peers,
totalCount,
selectedPeerId,
onSelect,
}: {
peers: Array<{ peerId: string; peerName: string; count: number; lastTs: string }>;
totalCount: number;
selectedPeerId: string;
onSelect: (peerId: string) => void;
}) {
// "All" + each peer, in tab-bar order. Built once per render and
// used both for click handling and for ArrowLeft/ArrowRight cycling.
const ids = [ALL_PEERS, ...peers.map((p) => p.peerId)];
return (
<div
role="tablist"
aria-label="Peer threads"
className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0 overflow-x-auto"
onKeyDown={(e) => {
const idx = ids.indexOf(selectedPeerId);
if (idx < 0) return;
if (e.key === "ArrowRight") {
e.preventDefault();
onSelect(ids[(idx + 1) % ids.length]);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
onSelect(ids[(idx - 1 + ids.length) % ids.length]);
}
}}
>
<PeerTabButton
active={selectedPeerId === ALL_PEERS}
onClick={() => onSelect(ALL_PEERS)}
label="All"
count={totalCount}
/>
{peers.map((p) => (
<PeerTabButton
key={p.peerId}
active={selectedPeerId === p.peerId}
onClick={() => onSelect(p.peerId)}
label={p.peerName}
count={p.count}
/>
))}
</div>
);
}
function PeerTabButton({
active,
onClick,
label,
count,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
}) {
return (
<button
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={onClick}
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
active
? "border-b-2 border-cyan-500 text-cyan-200"
: "border-b-2 border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{label} <span className="text-[9px] text-zinc-500">({count})</span>
</button>
);
}
function NormalMessage({ msg }: { msg: CommMessage }) {
return (
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>

View File

@ -15,7 +15,7 @@ vi.mock("@/store/canvas", () => ({
},
}));
import { toCommMessage, type ActivityEntry } from "../AgentCommsPanel";
import { toCommMessage, buildPeerSummary, type ActivityEntry } from "../AgentCommsPanel";
const SELF = "ws-self";
const PEER = "ws-peer";
@ -172,3 +172,79 @@ describe("toCommMessage — flow derivation", () => {
expect(m).toBeNull();
});
});
// --- buildPeerSummary — peer-tab ordering + counts -------------------
//
// The grouped view sorts peer tabs by most-recent activity descending
// (Slack-style DM list) so active conversations rise to the top.
// These tests pin that ordering plus the count aggregation. Pure
// helper — no React render required.
describe("buildPeerSummary", () => {
function msg(peerId: string, peerName: string, timestamp: string): never {
// Cast through unknown — we only need the fields buildPeerSummary
// reads (peerId, peerName, timestamp). Other CommMessage fields
// are irrelevant to the sort/count logic.
return {
id: `id-${peerId}-${timestamp}`,
flow: "out",
peerId,
peerName,
text: "",
responseText: null,
status: "ok",
timestamp,
} as never;
}
it("collapses messages into one row per peer with correct count", () => {
const summary = buildPeerSummary([
msg("ws-a", "Alpha", "2026-04-25T10:00:00Z"),
msg("ws-a", "Alpha", "2026-04-25T10:01:00Z"),
msg("ws-b", "Bravo", "2026-04-25T10:02:00Z"),
]);
expect(summary).toHaveLength(2);
const byId = new Map(summary.map((s) => [s.peerId, s]));
expect(byId.get("ws-a")?.count).toBe(2);
expect(byId.get("ws-b")?.count).toBe(1);
});
it("orders peers by most-recent activity DESC", () => {
// ws-old's last activity was at 10:00, ws-new's was at 10:30 —
// ws-new should sort first because it's more recently active.
const summary = buildPeerSummary([
msg("ws-old", "Old", "2026-04-25T09:00:00Z"),
msg("ws-old", "Old", "2026-04-25T10:00:00Z"),
msg("ws-new", "New", "2026-04-25T10:30:00Z"),
]);
expect(summary[0].peerId).toBe("ws-new");
expect(summary[1].peerId).toBe("ws-old");
});
it("tracks lastTs as the maximum timestamp across that peer's messages", () => {
// Out-of-order messages — buildPeerSummary should still pick the
// newest. Pre-fix a naive "last-seen-wins" would have set lastTs
// to the second message's timestamp (older).
const summary = buildPeerSummary([
msg("ws-a", "Alpha", "2026-04-25T11:00:00Z"),
msg("ws-a", "Alpha", "2026-04-25T09:00:00Z"),
msg("ws-a", "Alpha", "2026-04-25T10:00:00Z"),
]);
expect(summary[0].lastTs).toBe("2026-04-25T11:00:00Z");
});
it("empty input returns empty array", () => {
expect(buildPeerSummary([])).toEqual([]);
});
it("preserves the peer's display name from the first occurrence", () => {
// If two messages for the same peerId carry different peerName
// (shouldn't happen in practice, but defensive), the first wins
// — matches what the user sees in the tile and avoids name flicker.
const summary = buildPeerSummary([
msg("ws-a", "Alpha", "2026-04-25T10:00:00Z"),
msg("ws-a", "Renamed", "2026-04-25T10:01:00Z"),
]);
expect(summary[0].peerName).toBe("Alpha");
});
});