feat(ui): add Realtime Events tab to team detail with structured event cards

Add a new "Realtime Events" tab in team detail page that displays WS events
in real-time via a centralized Zustand store + wildcard listener. Includes
structured card components for delegation, task, message, agent, and CRUD
events with agent name resolution from TanStack Query cache. Each card is
clickable to show raw JSON with syntax highlighting and copy support.

Also removes byte-based task truncation in delegation event payloads that
was corrupting multi-byte UTF-8 characters — now sends full task content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-03-05 14:21:34 +07:00
1 parent f426460358
commit 9b9e99e72a
18 files changed
+1159 -4

No files matched your search

+47
View File
@@ -167,8 +167,55 @@ export const Events = {
HEARTBEAT: "heartbeat",
TALK_MODE: "talk.mode",
HANDOFF: "handoff",
// Delegation lifecycle
DELEGATION_STARTED: "delegation.started",
DELEGATION_COMPLETED: "delegation.completed",
DELEGATION_FAILED: "delegation.failed",
DELEGATION_CANCELLED: "delegation.cancelled",
DELEGATION_PROGRESS: "delegation.progress",
DELEGATION_ACCUMULATED: "delegation.accumulated",
DELEGATION_ANNOUNCE: "delegation.announce",
DELEGATION_QUALITY_GATE_RETRY: "delegation.quality_gate.retry",
// Team tasks
TEAM_TASK_CREATED: "team.task.created",
TEAM_TASK_CLAIMED: "team.task.claimed",
TEAM_TASK_COMPLETED: "team.task.completed",
TEAM_TASK_CANCELLED: "team.task.cancelled",
// Team messages
TEAM_MESSAGE_SENT: "team.message.sent",
// Team CRUD
TEAM_CREATED: "team.created",
TEAM_UPDATED: "team.updated",
TEAM_DELETED: "team.deleted",
TEAM_MEMBER_ADDED: "team.member.added",
TEAM_MEMBER_REMOVED: "team.member.removed",
// Agent links
AGENT_LINK_CREATED: "agent_link.created",
AGENT_LINK_UPDATED: "agent_link.updated",
AGENT_LINK_DELETED: "agent_link.deleted",
} as const;
/** All event names relevant to team debug view */
export const TEAM_RELATED_EVENTS: Set<string> = new Set([
Events.DELEGATION_STARTED, Events.DELEGATION_COMPLETED,
Events.DELEGATION_FAILED, Events.DELEGATION_CANCELLED,
Events.DELEGATION_PROGRESS, Events.DELEGATION_ACCUMULATED,
Events.DELEGATION_ANNOUNCE, Events.DELEGATION_QUALITY_GATE_RETRY,
Events.TEAM_TASK_CREATED, Events.TEAM_TASK_CLAIMED,
Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED,
Events.TEAM_MESSAGE_SENT,
Events.TEAM_CREATED, Events.TEAM_UPDATED, Events.TEAM_DELETED,
Events.TEAM_MEMBER_ADDED, Events.TEAM_MEMBER_REMOVED,
Events.AGENT_LINK_CREATED, Events.AGENT_LINK_UPDATED,
Events.AGENT_LINK_DELETED,
Events.AGENT,
]);
// Agent event subtypes (in payload.type)
export const AgentEventTypes = {
RUN_STARTED: "run.started",
@@ -1,9 +1,12 @@
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useRef, useMemo, useCallback } from "react";
import { WsClient, type ConnectionState } from "@/api/ws-client";
import { HttpClient } from "@/api/http-client";
import { WsContext } from "@/hooks/use-ws";
import { useAuthStore } from "@/stores/use-auth-store";
import { useWsQueryInvalidation } from "@/hooks/use-query-invalidation";
import { useWsEvent } from "@/hooks/use-ws-event";
import { TEAM_RELATED_EVENTS } from "@/api/protocol";
import { useTeamEventStore } from "@/stores/use-team-event-store";
// In dev mode, connect directly to backend WS (bypass Vite proxy).
// In production, use relative "/ws" path.
@@ -59,6 +62,7 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
return (
<WsContext.Provider value={value}>
<WsQueryInvalidation />
<WsTeamEventCapture />
{children}
</WsContext.Provider>
);
@@ -68,3 +72,25 @@ function WsQueryInvalidation() {
useWsQueryInvalidation();
return null;
}
/** Captures all team-related WS events into the Zustand store. */
function WsTeamEventCapture() {
const addEvent = useTeamEventStore((s) => s.addEvent);
const handler = useCallback(
(raw: unknown) => {
const { event, payload } = raw as { event: string; payload: unknown };
if (!TEAM_RELATED_EVENTS.has(event)) return;
// Skip noisy chunk/thinking subtypes for agent events
if (event === "agent") {
const p = payload as { type?: string };
if (p.type === "chunk" || p.type === "thinking") return;
}
addEvent(event, payload);
},
[addEvent],
);
useWsEvent("*", handler);
return null;
}
@@ -111,7 +111,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
{/* Tabs */}
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto">
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
@@ -95,7 +95,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
{/* Tabs */}
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto">
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="credentials">Credentials</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
+1 -1
View File
@@ -123,7 +123,7 @@ export function ConfigPage() {
<TabsList
variant={isMobile ? "default" : "line"}
className={isMobile
? "w-full overflow-x-auto"
? "w-full overflow-x-auto overflow-y-hidden"
: "w-44 shrink-0 sticky top-6 rounded-lg border bg-card p-3 shadow-sm"
}
>
@@ -0,0 +1,58 @@
import { Badge } from "@/components/ui/badge";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import type { EnrichedAgentEventPayload } from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function AgentEventCard({ entry, resolveAgent }: Props) {
const p = entry.payload as EnrichedAgentEventPayload;
const subtype = p.type;
const subtypeBadgeVariant =
subtype === "run.completed"
? ("success" as const)
: subtype === "run.failed"
? ("destructive" as const)
: subtype === "run.started" || subtype === "run.retrying"
? ("info" as const)
: ("secondary" as const);
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
<Badge variant={subtypeBadgeVariant} className="shrink-0 font-mono text-xs">
{subtype}
</Badge>
<span className="truncate font-medium">{resolveAgent(p.agentId)}</span>
{p.delegationId && (
<span className="shrink-0 text-xs text-muted-foreground">
deleg: {p.delegationId.slice(0, 8)}
</span>
)}
{p.parentAgentId && (
<span className="truncate text-xs text-muted-foreground">
parent: {resolveAgent(p.parentAgentId)}
</span>
)}
</div>
{(subtype === "tool.call" || subtype === "tool.result") && p.payload && (
<div className="flex items-center gap-1 text-xs">
{p.payload.name && (
<span className="truncate font-mono font-medium">{p.payload.name}</span>
)}
{p.payload.is_error && (
<Badge variant="destructive" className="shrink-0 text-xs">error</Badge>
)}
</div>
)}
{subtype === "run.failed" && p.payload?.error && (
<p className="break-words text-xs text-destructive line-clamp-2">{p.payload.error}</p>
)}
</div>
);
}
@@ -0,0 +1,139 @@
import { Badge } from "@/components/ui/badge";
import { formatDuration } from "@/lib/format";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import type {
DelegationEventPayload,
DelegationProgressPayload,
DelegationAccumulatedPayload,
DelegationAnnouncePayload,
QualityGateRetryPayload,
} from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function DelegationEventCard({ entry, resolveAgent }: Props) {
switch (entry.event) {
case "delegation.started":
case "delegation.completed":
case "delegation.failed":
case "delegation.cancelled":
return <DelegationLifecycleCard entry={entry} resolveAgent={resolveAgent} />;
case "delegation.progress":
return <DelegationProgressCard payload={entry.payload as DelegationProgressPayload} resolveAgent={resolveAgent} />;
case "delegation.accumulated":
return <DelegationAccumulatedCard payload={entry.payload as DelegationAccumulatedPayload} resolveAgent={resolveAgent} />;
case "delegation.announce":
return <DelegationAnnounceCard payload={entry.payload as DelegationAnnouncePayload} resolveAgent={resolveAgent} />;
case "delegation.quality_gate.retry":
return <QualityGateRetryCard payload={entry.payload as QualityGateRetryPayload} resolveAgent={resolveAgent} />;
default:
return <pre className="overflow-x-auto text-xs">{JSON.stringify(entry.payload, null, 2)}</pre>;
}
}
function DelegationLifecycleCard({ entry, resolveAgent }: Props) {
const p = entry.payload as DelegationEventPayload;
const source = p.source_display_name || resolveAgent(p.source_agent_key);
const target = p.target_display_name || resolveAgent(p.target_agent_key);
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span className="truncate font-medium">{source}</span>
<span className="shrink-0 text-muted-foreground">&rarr;</span>
<span className="truncate font-medium">{target}</span>
{p.mode && (
<Badge variant="outline" className="shrink-0 text-xs">
{p.mode}
</Badge>
)}
</div>
{p.task && <p className="break-words text-xs text-muted-foreground line-clamp-2">{p.task}</p>}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{p.elapsed_ms != null && p.elapsed_ms > 0 && <span>{formatDuration(p.elapsed_ms)}</span>}
{p.error && <span className="break-all text-destructive">{p.error}</span>}
</div>
</div>
);
}
type ResolverProp = { resolveAgent: (keyOrId: string | undefined) => string };
function DelegationProgressCard({ payload: p, resolveAgent }: { payload: DelegationProgressPayload } & ResolverProp) {
return (
<div className="space-y-1 text-sm">
<p className="truncate text-xs text-muted-foreground">
{resolveAgent(p.source_agent_key)} has {p.active_delegations.length} active delegation(s)
</p>
<div className="space-y-0.5">
{p.active_delegations.map((d) => (
<div key={d.delegation_id} className="flex min-w-0 items-center gap-2 text-xs">
<span className="truncate font-medium">{d.target_display_name || resolveAgent(d.target_agent_key)}</span>
<span className="shrink-0 text-muted-foreground">{formatDuration(d.elapsed_ms)}</span>
</div>
))}
</div>
</div>
);
}
function DelegationAccumulatedCard({ payload: p, resolveAgent }: { payload: DelegationAccumulatedPayload } & ResolverProp) {
return (
<div className="text-sm">
<span className="font-medium">{p.target_display_name || resolveAgent(p.target_agent_key)}</span>
<span className="text-muted-foreground"> result accumulated, </span>
<span>{p.siblings_remaining} sibling(s) remaining</span>
{p.elapsed_ms != null && p.elapsed_ms > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({formatDuration(p.elapsed_ms)})
</span>
)}
</div>
);
}
function DelegationAnnounceCard({ payload: p, resolveAgent }: { payload: DelegationAnnouncePayload } & ResolverProp) {
return (
<div className="space-y-1.5 text-sm">
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{p.source_display_name || resolveAgent(p.source_agent_key)}</span>
{" "}announcing {p.results.length} result(s)
<span className="ml-1 text-xs">({formatDuration(p.total_elapsed_ms)})</span>
</p>
<div className="space-y-1">
{p.results.map((r) => (
<div key={r.agent_key} className="min-w-0 text-xs">
<div className="flex min-w-0 items-start gap-1.5">
<span className="shrink-0 font-medium">{r.display_name || resolveAgent(r.agent_key)}</span>
{r.has_media && (
<Badge variant="outline" className="shrink-0 text-xs">media</Badge>
)}
</div>
{r.content_preview && (
<p className="mt-0.5 break-words text-muted-foreground line-clamp-1">{r.content_preview}</p>
)}
</div>
))}
</div>
</div>
);
}
function QualityGateRetryCard({ payload: p, resolveAgent }: { payload: QualityGateRetryPayload } & ResolverProp) {
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate font-medium">{resolveAgent(p.target_agent_key)}</span>
<Badge variant="warning" className="shrink-0 text-xs">
retry {p.attempt}/{p.max_retries}
</Badge>
<Badge variant="outline" className="shrink-0 text-xs">{p.gate_type}</Badge>
</div>
{p.feedback && (
<p className="break-words text-xs text-muted-foreground line-clamp-2">{p.feedback}</p>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { formatRelativeTime } from "@/lib/format";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import { useAgentResolver } from "./use-agent-resolver";
import { DelegationEventCard } from "./delegation-event-cards";
import { TaskEventCard } from "./task-event-cards";
import { MessageEventCard } from "./message-event-card";
import { AgentEventCard } from "./agent-event-cards";
import { TeamCrudEventCard } from "./team-crud-event-cards";
import { EventDetailDialog } from "./event-detail-dialog";
interface EventCardProps {
entry: TeamEventEntry;
}
export function EventCard({ entry }: EventCardProps) {
const { event } = entry;
const { resolveAgent } = useAgentResolver();
const [showDetail, setShowDetail] = useState(false);
let content: React.ReactNode;
if (event.startsWith("delegation.")) {
content = <DelegationEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (event.startsWith("team.task.")) {
content = <TaskEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (event === "team.message.sent") {
content = <MessageEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (event === "agent") {
content = <AgentEventCard entry={entry} resolveAgent={resolveAgent} />;
} else if (
event.startsWith("team.created") ||
event.startsWith("team.updated") ||
event.startsWith("team.deleted") ||
event.startsWith("team.member.") ||
event.startsWith("agent_link.")
) {
content = <TeamCrudEventCard entry={entry} resolveAgent={resolveAgent} />;
} else {
content = (
<pre className="overflow-x-auto text-xs text-muted-foreground">
{JSON.stringify(entry.payload, null, 2)}
</pre>
);
}
return (
<>
<button
type="button"
onClick={() => setShowDetail(true)}
className="w-full cursor-pointer overflow-hidden rounded-lg border bg-card p-3 text-left transition-colors hover:border-primary/20"
>
<div className="mb-1.5 flex items-center justify-between gap-2">
<EventTypeBadge event={event} />
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelativeTime(new Date(entry.timestamp))}
</span>
</div>
<div className="min-w-0">{content}</div>
</button>
{showDetail && (
<EventDetailDialog entry={entry} onClose={() => setShowDetail(false)} />
)}
</>
);
}
function EventTypeBadge({ event }: { event: string }) {
const variant =
event.includes("failed") || event.includes("cancelled") || event.includes("deleted")
? "destructive"
: event.includes("completed") || event.includes("created") || event.includes("added")
? "success"
: event.includes("started") || event.includes("progress") || event.includes("claimed")
? "info"
: "secondary";
return (
<Badge variant={variant} className="shrink-0 font-mono text-xs">
{event}
</Badge>
);
}
@@ -0,0 +1,119 @@
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { useClipboard } from "@/hooks/use-clipboard";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
interface EventDetailDialogProps {
entry: TeamEventEntry;
onClose: () => void;
}
export function EventDetailDialog({ entry, onClose }: EventDetailDialogProps) {
const json = JSON.stringify(entry.payload, null, 2);
const { copied, copy } = useClipboard();
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
{entry.event}
</Badge>
<span className="text-sm font-normal text-muted-foreground">
{new Date(entry.timestamp).toLocaleString()}
</span>
</DialogTitle>
</DialogHeader>
<div className="relative">
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 z-10 h-7 gap-1 text-xs"
onClick={() => copy(json)}
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? "Copied" : "Copy"}
</Button>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-4 text-sm leading-relaxed">
<code>
<JsonHighlight json={json} />
</code>
</pre>
</div>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
);
}
/** Simple JSON syntax highlighter — no external deps. */
function JsonHighlight({ json }: { json: string }) {
const parts = json.split(
/("(?:\\.|[^"\\])*")\s*(:)?|(\btrue\b|\bfalse\b|\bnull\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
);
return (
<>
{parts.map((part, i) => {
if (part === undefined || part === "") return null;
// String key (followed by colon in next capture group)
if (part.startsWith('"') && parts[i + 1] === ":") {
return (
<span key={i} className="text-sky-600 dark:text-sky-400">
{part}
</span>
);
}
// Colon separator
if (part === ":") {
return (
<span key={i} className="text-muted-foreground">
{part}
</span>
);
}
// String value
if (part.startsWith('"')) {
return (
<span key={i} className="text-emerald-600 dark:text-emerald-400">
{part}
</span>
);
}
// Boolean / null
if (part === "true" || part === "false" || part === "null") {
return (
<span key={i} className="text-amber-600 dark:text-amber-400">
{part}
</span>
);
}
// Number
if (/^-?\d/.test(part)) {
return (
<span key={i} className="text-violet-600 dark:text-violet-400">
{part}
</span>
);
}
// Punctuation / whitespace
return <span key={i}>{part}</span>;
})}
</>
);
}
@@ -0,0 +1 @@
export { EventCard } from "./event-card";
@@ -0,0 +1,32 @@
import { Badge } from "@/components/ui/badge";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import type { TeamMessageEventPayload } from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function MessageEventCard({ entry, resolveAgent }: Props) {
const p = entry.payload as TeamMessageEventPayload;
const from = p.from_display_name || resolveAgent(p.from_agent_key);
const to =
p.to_agent_key === "broadcast"
? "all"
: p.to_display_name || resolveAgent(p.to_agent_key);
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span className="truncate font-medium">{from}</span>
<span className="shrink-0 text-muted-foreground">&rarr;</span>
<span className="truncate font-medium">{to}</span>
<Badge variant="outline" className="shrink-0 text-xs">
{p.message_type}
</Badge>
</div>
{p.preview && (
<p className="break-words text-xs text-muted-foreground line-clamp-2">{p.preview}</p>
)}
</div>
);
}
@@ -0,0 +1,28 @@
import { Badge } from "@/components/ui/badge";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import { taskStatusBadgeVariant } from "../task-sections";
import type { TeamTaskEventPayload } from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function TaskEventCard({ entry, resolveAgent }: Props) {
const p = entry.payload as TeamTaskEventPayload;
const owner = p.owner_display_name || resolveAgent(p.owner_agent_key);
return (
<div className="space-y-1 text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
{p.subject && <span className="min-w-0 truncate font-medium">{p.subject}</span>}
<Badge variant={taskStatusBadgeVariant(p.status)} className="shrink-0 text-xs">
{p.status}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{p.owner_agent_key && <span className="truncate">Owner: {owner}</span>}
{p.reason && <span className="break-words">Reason: {p.reason}</span>}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { Badge } from "@/components/ui/badge";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import type {
TeamCreatedPayload,
TeamUpdatedPayload,
TeamDeletedPayload,
TeamMemberAddedPayload,
TeamMemberRemovedPayload,
AgentLinkCreatedPayload,
AgentLinkUpdatedPayload,
AgentLinkDeletedPayload,
} from "@/types/team-events";
interface Props {
entry: TeamEventEntry;
resolveAgent: (keyOrId: string | undefined) => string;
}
export function TeamCrudEventCard({ entry, resolveAgent }: Props) {
switch (entry.event) {
case "team.created":
return <TeamCreatedCard payload={entry.payload as TeamCreatedPayload} resolveAgent={resolveAgent} />;
case "team.updated":
return <TeamUpdatedCard payload={entry.payload as TeamUpdatedPayload} />;
case "team.deleted":
return <TeamDeletedCard payload={entry.payload as TeamDeletedPayload} />;
case "team.member.added":
return <MemberAddedCard payload={entry.payload as TeamMemberAddedPayload} resolveAgent={resolveAgent} />;
case "team.member.removed":
return <MemberRemovedCard payload={entry.payload as TeamMemberRemovedPayload} resolveAgent={resolveAgent} />;
case "agent_link.created":
return <LinkCreatedCard payload={entry.payload as AgentLinkCreatedPayload} resolveAgent={resolveAgent} />;
case "agent_link.updated":
return <LinkUpdatedCard payload={entry.payload as AgentLinkUpdatedPayload} resolveAgent={resolveAgent} />;
case "agent_link.deleted":
return <LinkDeletedCard payload={entry.payload as AgentLinkDeletedPayload} resolveAgent={resolveAgent} />;
default:
return <pre className="overflow-x-auto text-xs">{JSON.stringify(entry.payload, null, 2)}</pre>;
}
}
type R = { resolveAgent: (keyOrId: string | undefined) => string };
function TeamCreatedCard({ payload: p, resolveAgent }: { payload: TeamCreatedPayload } & R) {
return (
<div className="text-sm">
<span>Team </span>
<span className="font-medium">{p.team_name}</span>
<span> created</span>
{p.lead_agent_key && (
<span className="text-muted-foreground">
{" "}(lead: {p.lead_display_name || resolveAgent(p.lead_agent_key)})
</span>
)}
<span className="ml-1 text-xs text-muted-foreground">{p.member_count} member(s)</span>
</div>
);
}
function TeamUpdatedCard({ payload: p }: { payload: TeamUpdatedPayload }) {
return (
<div className="text-sm">
<span>Team </span>
<span className="font-medium">{p.team_name}</span>
<span> updated</span>
{p.changes.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">({p.changes.join(", ")})</span>
)}
</div>
);
}
function TeamDeletedCard({ payload: p }: { payload: TeamDeletedPayload }) {
return (
<div className="text-sm text-destructive">
<span>Team </span>
<span className="font-medium">{p.team_name}</span>
<span> deleted</span>
</div>
);
}
function MemberAddedCard({ payload: p, resolveAgent }: { payload: TeamMemberAddedPayload } & R) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 text-sm">
<span className="truncate font-medium">{p.display_name || resolveAgent(p.agent_key)}</span>
<span className="shrink-0 text-muted-foreground">added to</span>
<span className="truncate font-medium">{p.team_name}</span>
<Badge variant="outline" className="shrink-0 text-xs">{p.role}</Badge>
</div>
);
}
function MemberRemovedCard({ payload: p, resolveAgent }: { payload: TeamMemberRemovedPayload } & R) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 text-sm">
<span className="truncate font-medium">{p.display_name || resolveAgent(p.agent_key)}</span>
<span className="shrink-0 text-muted-foreground">removed from</span>
<span className="truncate font-medium">{p.team_name}</span>
</div>
);
}
function LinkCreatedCard({ payload: p, resolveAgent }: { payload: AgentLinkCreatedPayload } & R) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 text-sm">
<span className="truncate font-medium">{resolveAgent(p.source_agent_key)}</span>
<span className="shrink-0 text-muted-foreground">&rarr;</span>
<span className="truncate font-medium">{resolveAgent(p.target_agent_key)}</span>
<Badge variant="outline" className="shrink-0 text-xs">{p.direction}</Badge>
<Badge variant="success" className="shrink-0 text-xs">{p.status}</Badge>
</div>
);
}
function LinkUpdatedCard({ payload: p, resolveAgent }: { payload: AgentLinkUpdatedPayload } & R) {
return (
<div className="text-sm">
<span>Link </span>
<span className="font-medium">
{resolveAgent(p.source_agent_key)} &rarr; {resolveAgent(p.target_agent_key)}
</span>
{p.changes.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">({p.changes.join(", ")})</span>
)}
</div>
);
}
function LinkDeletedCard({ payload: p, resolveAgent }: { payload: AgentLinkDeletedPayload } & R) {
return (
<div className="text-sm text-destructive">
<span>Link </span>
<span className="font-medium">
{resolveAgent(p.source_agent_key)} &rarr; {resolveAgent(p.target_agent_key)}
</span>
<span> deleted</span>
</div>
);
}
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import type { AgentData } from "@/types/agent";
/**
* Build lookup maps from the TanStack Query agents cache.
* Returns a resolve function: (keyOrId) → display_name | fallback.
* The cache is already populated by useAgents() which runs on the agents page.
*/
export function useAgentResolver() {
const queryClient = useQueryClient();
const agents = queryClient.getQueryData<AgentData[]>(queryKeys.agents.all);
const { byKey, byId } = useMemo(() => {
const byKey = new Map<string, AgentData>();
const byId = new Map<string, AgentData>();
for (const a of agents ?? []) {
if (a.agent_key) byKey.set(a.agent_key, a);
if (a.id) byId.set(a.id, a);
}
return { byKey, byId };
}, [agents]);
/** Resolve agent_key or UUID to display name. Falls back to the input string. */
const resolveAgent = (keyOrId: string | undefined): string => {
if (!keyOrId) return "";
const agent = byKey.get(keyOrId) ?? byId.get(keyOrId);
return agent?.display_name || agent?.agent_key || keyOrId;
};
return { resolveAgent };
}
@@ -9,6 +9,7 @@ import { TeamMembersTab } from "./team-members-tab";
import { TeamTasksTab } from "./team-tasks-tab";
import { TeamDelegationsTab } from "./team-delegations-tab";
import { TeamSettingsTab } from "./team-settings-tab";
import { TeamEventsTab } from "./team-events-tab";
import type { TeamData, TeamMemberData } from "@/types/team";
interface TeamDetailPageProps {
@@ -112,6 +113,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
<TabsTrigger value="delegations">Delegations</TabsTrigger>
<TabsTrigger value="events">Realtime Events</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
@@ -132,6 +134,10 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
<TeamDelegationsTab teamId={teamId} />
</TabsContent>
<TabsContent value="events" className="mt-4">
<TeamEventsTab teamId={teamId} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<TeamSettingsTab teamId={teamId} team={team} onSaved={reload} />
</TabsContent>
+168
View File
@@ -0,0 +1,168 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { Radio, Trash2, Pause, Play, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/empty-state";
import { useTeamEventStore } from "@/stores/use-team-event-store";
import { EventCard } from "./event-sections";
const EVENT_CATEGORIES = [
{ label: "All", value: "all" },
{ label: "Delegation", value: "delegation" },
{ label: "Task", value: "team.task" },
{ label: "Message", value: "team.message" },
{ label: "Agent", value: "agent" },
{ label: "Team CRUD", value: "team.crud" },
{ label: "Agent Link", value: "agent_link" },
] as const;
interface TeamEventsTabProps {
teamId: string;
}
export function TeamEventsTab({ teamId }: TeamEventsTabProps) {
const allEvents = useTeamEventStore((s) => s.events);
const paused = useTeamEventStore((s) => s.paused);
const setPaused = useTeamEventStore((s) => s.setPaused);
const clear = useTeamEventStore((s) => s.clear);
const [categoryFilter, setCategoryFilter] = useState("all");
const [isAtBottom, setIsAtBottom] = useState(true);
const feedRef = useRef<HTMLDivElement>(null);
// Filter events for this team (null teamId = global events like team.created)
const teamEvents = useMemo(
() => allEvents.filter((e) => e.teamId === teamId || e.teamId === null),
[allEvents, teamId],
);
// Apply category filter
const filteredEvents = useMemo(() => {
if (categoryFilter === "all") return teamEvents;
if (categoryFilter === "team.crud") {
return teamEvents.filter(
(e) =>
e.event === "team.created" ||
e.event === "team.updated" ||
e.event === "team.deleted" ||
e.event.startsWith("team.member."),
);
}
return teamEvents.filter((e) => e.event.startsWith(categoryFilter));
}, [teamEvents, categoryFilter]);
// Auto-scroll to bottom when new events arrive (only if already at bottom)
useEffect(() => {
if (isAtBottom && feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [filteredEvents.length, isAtBottom]);
const handleScroll = () => {
if (!feedRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = feedRef.current;
setIsAtBottom(scrollHeight - scrollTop - clientHeight < 50);
};
const scrollToBottom = useCallback(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
setIsAtBottom(true);
}, []);
return (
<div className="rounded-md border">
{/* Header — status + controls */}
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Badge variant={paused ? "warning" : "success"} className="text-xs">
{paused ? "Paused" : "Live"}
</Badge>
<span className="text-xs text-muted-foreground">
{filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""}
{teamEvents.length !== filteredEvents.length && (
<span> / {teamEvents.length} total</span>
)}
</span>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => setPaused(!paused)}
className="h-7 gap-1 px-2 text-xs"
>
{paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
{paused ? "Resume" : "Pause"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clear}
className="h-7 gap-1 px-2 text-xs"
>
<Trash2 className="h-3 w-3" /> Clear
</Button>
</div>
</div>
{/* Category filter pills */}
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
{EVENT_CATEGORIES.map((cat) => (
<button
key={cat.value}
type="button"
onClick={() => setCategoryFilter(cat.value)}
className={`rounded-full px-2.5 py-0.5 text-xs transition-colors ${
categoryFilter === cat.value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
}`}
>
{cat.label}
</button>
))}
</div>
{/* Event feed */}
{filteredEvents.length === 0 ? (
<div className="px-4 py-12">
<EmptyState
icon={Radio}
title="No events yet"
description={
paused
? "Event capture is paused. Resume to see new events."
: "Waiting for real-time team events... Events will appear as team agents work."
}
/>
</div>
) : (
<div className="relative">
<div
ref={feedRef}
onScroll={handleScroll}
className="max-h-[calc(100vh-400px)] min-h-[200px] space-y-2 overflow-y-auto p-3"
>
{filteredEvents.map((entry) => (
<EventCard key={entry.id} entry={entry} />
))}
</div>
{/* Scroll-to-bottom FAB — shown when user scrolled up */}
{!isAtBottom && (
<button
type="button"
onClick={scrollToBottom}
className="absolute bottom-4 right-4 flex h-8 w-8 items-center justify-center rounded-full border bg-background shadow-md transition-colors hover:bg-muted"
title="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { create } from "zustand";
const MAX_EVENTS = 500;
/** A single captured WS event entry */
export interface TeamEventEntry {
id: number;
event: string;
payload: unknown;
timestamp: number;
teamId: string | null;
}
interface TeamEventState {
events: TeamEventEntry[];
paused: boolean;
addEvent: (event: string, payload: unknown) => void;
clear: () => void;
setPaused: (paused: boolean) => void;
}
/**
* Extract team_id from any known payload shape.
* Delegation/team events use snake_case `team_id`,
* enriched agent events use camelCase `teamId`.
*/
function extractTeamId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const p = payload as Record<string, unknown>;
if (typeof p.team_id === "string" && p.team_id) return p.team_id;
if (typeof p.teamId === "string" && p.teamId) return p.teamId;
return null;
}
let counter = 0;
export const useTeamEventStore = create<TeamEventState>((set) => ({
events: [],
paused: false,
addEvent: (event, payload) => {
set((s) => {
if (s.paused) return s;
const entry: TeamEventEntry = {
id: ++counter,
event,
payload,
timestamp: Date.now(),
teamId: extractTeamId(payload),
};
const next = [...s.events, entry];
if (next.length > MAX_EVENTS) {
return { events: next.slice(next.length - MAX_EVENTS) };
}
return { events: next };
});
},
clear: () => set({ events: [] }),
setPaused: (paused) => set({ paused }),
}));
+212
View File
@@ -0,0 +1,212 @@
/** TypeScript interfaces mirroring Go pkg/protocol/team_events.go */
// --- Delegation events ---
export interface DelegationEventPayload {
delegation_id: string;
source_agent_id: string;
source_agent_key: string;
source_display_name?: string;
target_agent_id: string;
target_agent_key: string;
target_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
mode: string;
task?: string;
team_id?: string;
team_task_id?: string;
status?: string;
elapsed_ms?: number;
error?: string;
created_at: string;
}
export interface DelegationProgressItem {
delegation_id: string;
target_agent_key: string;
target_display_name?: string;
elapsed_ms: number;
team_task_id?: string;
}
export interface DelegationProgressPayload {
source_agent_id: string;
source_agent_key: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
active_delegations: DelegationProgressItem[];
}
export interface DelegationAccumulatedPayload {
delegation_id: string;
source_agent_id: string;
source_agent_key: string;
target_agent_key: string;
target_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
team_task_id?: string;
siblings_remaining: number;
elapsed_ms?: number;
}
export interface DelegationAnnounceResultSummary {
agent_key: string;
display_name?: string;
has_media: boolean;
content_preview?: string;
}
export interface DelegationAnnouncePayload {
source_agent_id: string;
source_agent_key: string;
source_display_name?: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
results: DelegationAnnounceResultSummary[];
completed_task_ids?: string[];
total_elapsed_ms: number;
has_media: boolean;
}
export interface QualityGateRetryPayload {
delegation_id: string;
target_agent_key: string;
user_id: string;
channel: string;
chat_id: string;
team_id?: string;
team_task_id?: string;
gate_type: string;
attempt: number;
max_retries: number;
feedback?: string;
}
// --- Team task events ---
export interface TeamTaskEventPayload {
team_id: string;
task_id: string;
subject?: string;
status: string;
owner_agent_key?: string;
owner_display_name?: string;
reason?: string;
user_id: string;
channel: string;
chat_id: string;
timestamp: string;
}
// --- Team message events ---
export interface TeamMessageEventPayload {
team_id: string;
from_agent_key: string;
from_display_name?: string;
to_agent_key: string;
to_display_name?: string;
message_type: string;
preview: string;
task_id?: string;
user_id: string;
channel: string;
chat_id: string;
}
// --- Team CRUD events ---
export interface TeamCreatedPayload {
team_id: string;
team_name: string;
lead_agent_key: string;
lead_display_name?: string;
member_count: number;
}
export interface TeamUpdatedPayload {
team_id: string;
team_name: string;
changes: string[];
}
export interface TeamDeletedPayload {
team_id: string;
team_name: string;
}
export interface TeamMemberAddedPayload {
team_id: string;
team_name: string;
agent_id: string;
agent_key: string;
display_name?: string;
role: string;
}
export interface TeamMemberRemovedPayload {
team_id: string;
team_name: string;
agent_id: string;
agent_key: string;
display_name?: string;
}
// --- Agent link events ---
export interface AgentLinkCreatedPayload {
link_id: string;
source_agent_id: string;
source_agent_key: string;
target_agent_id: string;
target_agent_key: string;
direction: string;
team_id?: string;
status: string;
}
export interface AgentLinkUpdatedPayload {
link_id: string;
source_agent_key: string;
target_agent_key: string;
direction?: string;
status?: string;
changes: string[];
}
export interface AgentLinkDeletedPayload {
link_id: string;
source_agent_key: string;
target_agent_key: string;
}
// --- Enriched agent events (from internal/agent/loop_types.go) ---
export interface EnrichedAgentEventPayload {
type: string;
agentId: string;
runId: string;
payload?: {
content?: string;
name?: string;
id?: string;
is_error?: boolean;
error?: string;
};
delegationId?: string;
teamId?: string;
teamTaskId?: string;
parentAgentId?: string;
userId?: string;
channel?: string;
chatId?: string;
}