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:
co-authored by
Claude Opus 4.8
parent
7589f62aca
commit
7352ed47ab
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user