diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index fd19c3a5..6564666f 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -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 ; +} + +// 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(); + 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 DD↔X 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; +}) { + const [selectedPeerId, setSelectedPeerId] = useState(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 ( -
- {messages.map((msg) => - msg.status === "error" ? ( - - ) : ( - - ), - )} -
+
+ +
+ {visible.map((msg) => + msg.status === "error" ? ( + + ) : ( + + ), + )} +
+
); } +/** 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 ( +
{ + 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]); + } + }} + > + onSelect(ALL_PEERS)} + label="All" + count={totalCount} + /> + {peers.map((p) => ( + onSelect(p.peerId)} + label={p.peerName} + count={p.count} + /> + ))} +
+ ); +} + +function PeerTabButton({ + active, + onClick, + label, + count, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; +}) { + return ( + + ); +} + function NormalMessage({ msg }: { msg: CommMessage }) { return (
diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts index 2e421ed4..453a310b 100644 --- a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts @@ -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"); + }); +});