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 (
-