feat(observe): team observation mode in the agent chat surface

Adds a "This agent | Team" scope toggle to the chat card's Observe view. Team
mode (TeamObserver) resolves the open agent's team (members + names via
/api/teams + /api/team/claws), lists the team's group rooms, and renders a live
timeline of agent.message / room.message / a2a.invoked among team members —
read-only, reusing the workspace SSE feed. No backend changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-28 21:46:47 -07:00
co-authored by Claude Opus 4.8
parent 7589f62aca
commit 7352ed47ab
2 changed files with 205 additions and 2 deletions
@@ -0,0 +1,180 @@
"use client";
// Read-only observer of a whole TEAM's inter-agent traffic — the per-agent
// "Observe" lens widened to every member of the open agent's team. It resolves
// the agent's team (members + names) once, lists the team's group rooms, and
// renders a LIVE timeline of agent-to-agent messages, room posts, and inbound
// A2A invocations among team members as they happen. No composer — observe only.
import { useEffect, useRef, useState } from "react";
import { Users, MessagesSquare, Hash, Globe } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch";
import { useLiveEvent } from "@/lib/live/useClawmatesLive";
import { relativeTime } from "@/lib/format/relative-time";
interface Room {
id: string;
subject: string;
kind: string;
participants: string[];
}
type FeedItem = {
key: number;
ts: string;
kind: "msg" | "room" | "a2a";
fromId: string;
toId?: string;
subject?: string;
text: string;
};
async function getJson<T>(url: string): Promise<T> {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`${url}${r.status}`);
return r.json() as Promise<T>;
}
export function TeamObserver({ agent }: { agent: Agent }) {
// The team's member ids (defaults to just this agent until resolved).
const [members, setMembers] = useState<Set<string>>(new Set([agent.id]));
const [names, setNames] = useState<Record<string, string>>({});
const [teamName, setTeamName] = useState<string | null>(null);
const [resolving, setResolving] = useState(true);
const [feed, setFeed] = useState<FeedItem[]>([]);
const seq = useRef(0);
// Re-scope + clear the feed when the open agent changes.
const [seen, setSeen] = useState(agent.id);
if (seen !== agent.id) {
setSeen(agent.id);
setMembers(new Set([agent.id]));
setTeamName(null);
setResolving(true);
setFeed([]);
}
// Resolve the agent's team (members + the workspace name map) once per agent.
useEffect(() => {
let alive = true;
(async () => {
try {
const [teams, claws] = await Promise.all([
getJson<{ id: string }[]>("/api/teams"),
getJson<{ id: string; name: string }[]>("/api/team/claws"),
]);
const nameMap = Object.fromEntries(claws.map((c) => [c.id, c.name]));
if (alive) setNames(nameMap);
for (const t of teams) {
const detail = await getJson<{ name: string; members: { claw_id: string }[] }>(`/api/teams/${t.id}`);
if (detail.members.some((m) => m.claw_id === agent.id)) {
if (!alive) return;
setMembers(new Set(detail.members.map((m) => m.claw_id)));
setTeamName(detail.name);
break;
}
}
} catch {
// best-effort: fall back to just this agent
} finally {
if (alive) setResolving(false);
}
})();
return () => {
alive = false;
};
}, [agent.id]);
const nameOf = (id: string) => names[id] || "a claw";
const push = (item: Omit<FeedItem, "key" | "ts">) => {
seq.current += 1;
const entry: FeedItem = { ...item, key: seq.current, ts: new Date().toISOString() };
setFeed((prev) => [entry, ...prev].slice(0, 100));
};
// Live overlay: any inter-agent event touching a team member.
useLiveEvent("agent.message", (d) => {
if (members.has(d.fromAgentId) || members.has(d.toAgentId)) {
push({ kind: "msg", fromId: d.fromAgentId, toId: d.toAgentId, text: d.text });
}
});
useLiveEvent("room.message", (d) => {
if (members.has(d.fromAgentId) || d.participantIds.some((id) => members.has(id))) {
push({ kind: "room", fromId: d.fromAgentId, subject: d.subject, text: d.text });
}
});
useLiveEvent("a2a.invoked", (d) => {
if (members.has(d.agentId)) {
push({ kind: "a2a", fromId: d.agentId, text: d.skill ? `skill: ${d.skill}` : "external task" });
}
});
const rooms = (useFetchJson<Room[]>("/api/claw-chat/rooms").data ?? []).filter(
(r) => r.kind === "room" && r.participants.some((id) => members.has(id)),
);
return (
<div className="flex h-full flex-col">
<div
className="flex shrink-0 items-center gap-2 border-b border-white/[0.06] px-4 py-2 text-xxs text-muted-foreground"
style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", letterSpacing: ".06em" }}
>
<Users aria-hidden size={12} className="text-coral" />
OBSERVING {(teamName ?? "TEAM").toUpperCase()} · {members.size} MEMBER{members.size === 1 ? "" : "S"} · READ-ONLY
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
{rooms.length > 0 ? (
<div className="mb-3">
<p className="pb-1 text-xxs uppercase tracking-wide text-muted-foreground">Rooms</p>
<ul className="flex flex-col gap-1">
{rooms.map((r) => (
<li key={r.id} className="flex items-center gap-2 rounded-(--radius) bg-subtle px-2 py-1 text-xs">
<Hash aria-hidden size={12} className="text-muted-foreground" />
<span className="truncate">{r.subject}</span>
<span className="ml-auto text-xxs text-muted-foreground">{r.participants.length}</span>
</li>
))}
</ul>
</div>
) : null}
<p className="pb-1 text-xxs uppercase tracking-wide text-muted-foreground">Live activity</p>
{feed.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-muted-foreground">
<MessagesSquare aria-hidden size={22} />
<p className="text-sm">{resolving ? "Resolving team…" : "Waiting for the team to talk."}</p>
<p className="text-xs">Messages, room posts &amp; A2A calls among the team appear here live.</p>
</div>
) : (
<ul aria-label="Team activity" className="flex flex-col gap-1.5">
{feed.map((f) => (
<li key={f.key} className="rounded-(--radius) bg-subtle px-2 py-1.5 text-xs">
<span className="flex items-center gap-1.5">
{f.kind === "room" ? (
<Hash aria-hidden size={11} className="text-muted-foreground" />
) : f.kind === "a2a" ? (
<Globe aria-hidden size={11} className="text-[#c98af0]" />
) : (
<MessagesSquare aria-hidden size={11} className="text-muted-foreground" />
)}
<span className="font-medium text-foreground">
{f.kind === "room"
? `${nameOf(f.fromId)} → #${f.subject || "room"}`
: f.kind === "a2a"
? `external A2A → ${nameOf(f.fromId)}`
: `${nameOf(f.fromId)}${f.toId ? nameOf(f.toId) : "?"}`}
</span>
<span className="ml-auto text-xxs text-muted-foreground">{relativeTime(f.ts)}</span>
</span>
<span className="block truncate pt-0.5 text-muted-foreground">{f.text}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}