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
@@ -8,7 +8,7 @@
|
|||||||
// the hook cleanly (useChat takes initialMessages only at mount).
|
// the hook cleanly (useChat takes initialMessages only at mount).
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Eye, MessageSquare, Minus, Plus } from "lucide-react";
|
import { Eye, MessageSquare, Minus, Plus, Users } from "lucide-react";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import type { HistoryMessage, Session } from "@/lib/api/sessions";
|
import type { HistoryMessage, Session } from "@/lib/api/sessions";
|
||||||
@@ -18,6 +18,7 @@ import { MessageList } from "@/components/chat/MessageList";
|
|||||||
import { Composer } from "@/components/chat/Composer";
|
import { Composer } from "@/components/chat/Composer";
|
||||||
import { WelcomeState } from "@/components/chat/WelcomeState";
|
import { WelcomeState } from "@/components/chat/WelcomeState";
|
||||||
import { AgentObserver } from "./AgentObserver";
|
import { AgentObserver } from "./AgentObserver";
|
||||||
|
import { TeamObserver } from "./TeamObserver";
|
||||||
|
|
||||||
function toUiMessage(entry: HistoryMessage): UiMessage | null {
|
function toUiMessage(entry: HistoryMessage): UiMessage | null {
|
||||||
if (entry.role === "system") return null;
|
if (entry.role === "system") return null;
|
||||||
@@ -46,6 +47,7 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
|
|||||||
const [initial, setInitial] = useState<UiMessage[]>([]);
|
const [initial, setInitial] = useState<UiMessage[]>([]);
|
||||||
const [phase, setPhase] = useState<"loading" | "ready" | "error">("loading");
|
const [phase, setPhase] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [observerMode, setObserverMode] = useState(false);
|
const [observerMode, setObserverMode] = useState(false);
|
||||||
|
const [observerScope, setObserverScope] = useState<"agent" | "team">("agent");
|
||||||
|
|
||||||
// Reset to loading when the claw changes (render-phase, not in the effect).
|
// Reset to loading when the claw changes (render-phase, not in the effect).
|
||||||
const [seenAgent, setSeenAgent] = useState(agent.id);
|
const [seenAgent, setSeenAgent] = useState(agent.id);
|
||||||
@@ -136,6 +138,27 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
|
|||||||
>
|
>
|
||||||
<Eye aria-hidden size={13} /> Observe
|
<Eye aria-hidden size={13} /> Observe
|
||||||
</button>
|
</button>
|
||||||
|
{/* When observing, widen the lens from this agent to its whole team. */}
|
||||||
|
{observerMode ? (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-md border border-white/10 p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={observerScope === "agent"}
|
||||||
|
onClick={() => setObserverScope("agent")}
|
||||||
|
className={`rounded px-2 py-0.5 text-xxs transition-colors ${observerScope === "agent" ? "bg-surface-warm-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
>
|
||||||
|
This agent
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={observerScope === "team"}
|
||||||
|
onClick={() => setObserverScope("team")}
|
||||||
|
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xxs transition-colors ${observerScope === "team" ? "bg-surface-warm-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||||
|
>
|
||||||
|
<Users aria-hidden size={11} /> Team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{/* Minimize the chat into the top-right launcher (mirrors the computer). */}
|
{/* Minimize the chat into the top-right launcher (mirrors the computer). */}
|
||||||
{onMinimize ? (
|
{onMinimize ? (
|
||||||
@@ -144,7 +167,7 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
{observerMode ? (
|
{observerMode ? (
|
||||||
<AgentObserver agent={agent} />
|
observerScope === "team" ? <TeamObserver agent={agent} /> : <AgentObserver agent={agent} />
|
||||||
) : phase === "loading" ? (
|
) : phase === "loading" ? (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Loading chat…</div>
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Loading chat…</div>
|
||||||
) : phase === "error" ? (
|
) : phase === "error" ? (
|
||||||
|
|||||||
@@ -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