From 9b9e99e72a0aade2e0128ad38beddd2cd70a43be Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 5 Mar 2026 14:21:34 +0700 Subject: [PATCH] feat(ui): add Realtime Events tab to team detail with structured event cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/web/src/api/protocol.ts | 47 ++++ .../src/components/providers/ws-provider.tsx | 28 ++- .../agents/agent-detail/agent-detail-page.tsx | 2 +- .../channel-detail/channel-detail-page.tsx | 2 +- ui/web/src/pages/config/config-page.tsx | 2 +- .../event-sections/agent-event-cards.tsx | 58 +++++ .../event-sections/delegation-event-cards.tsx | 139 ++++++++++++ .../pages/teams/event-sections/event-card.tsx | 85 +++++++ .../event-sections/event-detail-dialog.tsx | 119 ++++++++++ .../src/pages/teams/event-sections/index.ts | 1 + .../event-sections/message-event-card.tsx | 32 +++ .../teams/event-sections/task-event-cards.tsx | 28 +++ .../event-sections/team-crud-event-cards.tsx | 140 ++++++++++++ .../event-sections/use-agent-resolver.ts | 33 +++ ui/web/src/pages/teams/team-detail-page.tsx | 6 + ui/web/src/pages/teams/team-events-tab.tsx | 168 ++++++++++++++ ui/web/src/stores/use-team-event-store.ts | 61 +++++ ui/web/src/types/team-events.ts | 212 ++++++++++++++++++ 18 files changed, 1159 insertions(+), 4 deletions(-) create mode 100644 ui/web/src/pages/teams/event-sections/agent-event-cards.tsx create mode 100644 ui/web/src/pages/teams/event-sections/delegation-event-cards.tsx create mode 100644 ui/web/src/pages/teams/event-sections/event-card.tsx create mode 100644 ui/web/src/pages/teams/event-sections/event-detail-dialog.tsx create mode 100644 ui/web/src/pages/teams/event-sections/index.ts create mode 100644 ui/web/src/pages/teams/event-sections/message-event-card.tsx create mode 100644 ui/web/src/pages/teams/event-sections/task-event-cards.tsx create mode 100644 ui/web/src/pages/teams/event-sections/team-crud-event-cards.tsx create mode 100644 ui/web/src/pages/teams/event-sections/use-agent-resolver.ts create mode 100644 ui/web/src/pages/teams/team-events-tab.tsx create mode 100644 ui/web/src/stores/use-team-event-store.ts create mode 100644 ui/web/src/types/team-events.ts diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 3a438159..de6e99a9 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -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 = 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", diff --git a/ui/web/src/components/providers/ws-provider.tsx b/ui/web/src/components/providers/ws-provider.tsx index 794b5828..e92cb524 100644 --- a/ui/web/src/components/providers/ws-provider.tsx +++ b/ui/web/src/components/providers/ws-provider.tsx @@ -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 ( + {children} ); @@ -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; +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index e8c52f1a..9bb589fe 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -111,7 +111,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { {/* Tabs */}
- + General Config Files diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index da47ab5e..4204ed04 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -95,7 +95,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps {/* Tabs */}
- + General Credentials Config diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index 7480beaa..1bdf5fd9 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -123,7 +123,7 @@ export function ConfigPage() { diff --git a/ui/web/src/pages/teams/event-sections/agent-event-cards.tsx b/ui/web/src/pages/teams/event-sections/agent-event-cards.tsx new file mode 100644 index 00000000..65acaf66 --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/agent-event-cards.tsx @@ -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 ( +
+
+ + {subtype} + + {resolveAgent(p.agentId)} + {p.delegationId && ( + + deleg: {p.delegationId.slice(0, 8)} + + )} + {p.parentAgentId && ( + + parent: {resolveAgent(p.parentAgentId)} + + )} +
+ + {(subtype === "tool.call" || subtype === "tool.result") && p.payload && ( +
+ {p.payload.name && ( + {p.payload.name} + )} + {p.payload.is_error && ( + error + )} +
+ )} + + {subtype === "run.failed" && p.payload?.error && ( +

{p.payload.error}

+ )} +
+ ); +} diff --git a/ui/web/src/pages/teams/event-sections/delegation-event-cards.tsx b/ui/web/src/pages/teams/event-sections/delegation-event-cards.tsx new file mode 100644 index 00000000..b54e143c --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/delegation-event-cards.tsx @@ -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 ; + case "delegation.progress": + return ; + case "delegation.accumulated": + return ; + case "delegation.announce": + return ; + case "delegation.quality_gate.retry": + return ; + default: + return
{JSON.stringify(entry.payload, null, 2)}
; + } +} + +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 ( +
+
+ {source} + → + {target} + {p.mode && ( + + {p.mode} + + )} +
+ {p.task &&

{p.task}

} +
+ {p.elapsed_ms != null && p.elapsed_ms > 0 && {formatDuration(p.elapsed_ms)}} + {p.error && {p.error}} +
+
+ ); +} + +type ResolverProp = { resolveAgent: (keyOrId: string | undefined) => string }; + +function DelegationProgressCard({ payload: p, resolveAgent }: { payload: DelegationProgressPayload } & ResolverProp) { + return ( +
+

+ {resolveAgent(p.source_agent_key)} has {p.active_delegations.length} active delegation(s) +

+
+ {p.active_delegations.map((d) => ( +
+ {d.target_display_name || resolveAgent(d.target_agent_key)} + {formatDuration(d.elapsed_ms)} +
+ ))} +
+
+ ); +} + +function DelegationAccumulatedCard({ payload: p, resolveAgent }: { payload: DelegationAccumulatedPayload } & ResolverProp) { + return ( +
+ {p.target_display_name || resolveAgent(p.target_agent_key)} + result accumulated, + {p.siblings_remaining} sibling(s) remaining + {p.elapsed_ms != null && p.elapsed_ms > 0 && ( + + ({formatDuration(p.elapsed_ms)}) + + )} +
+ ); +} + +function DelegationAnnounceCard({ payload: p, resolveAgent }: { payload: DelegationAnnouncePayload } & ResolverProp) { + return ( +
+

+ {p.source_display_name || resolveAgent(p.source_agent_key)} + {" "}announcing {p.results.length} result(s) + ({formatDuration(p.total_elapsed_ms)}) +

+
+ {p.results.map((r) => ( +
+
+ {r.display_name || resolveAgent(r.agent_key)} + {r.has_media && ( + media + )} +
+ {r.content_preview && ( +

{r.content_preview}

+ )} +
+ ))} +
+
+ ); +} + +function QualityGateRetryCard({ payload: p, resolveAgent }: { payload: QualityGateRetryPayload } & ResolverProp) { + return ( +
+
+ {resolveAgent(p.target_agent_key)} + + retry {p.attempt}/{p.max_retries} + + {p.gate_type} +
+ {p.feedback && ( +

{p.feedback}

+ )} +
+ ); +} diff --git a/ui/web/src/pages/teams/event-sections/event-card.tsx b/ui/web/src/pages/teams/event-sections/event-card.tsx new file mode 100644 index 00000000..03f6ad7d --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/event-card.tsx @@ -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 = ; + } else if (event.startsWith("team.task.")) { + content = ; + } else if (event === "team.message.sent") { + content = ; + } else if (event === "agent") { + content = ; + } else if ( + event.startsWith("team.created") || + event.startsWith("team.updated") || + event.startsWith("team.deleted") || + event.startsWith("team.member.") || + event.startsWith("agent_link.") + ) { + content = ; + } else { + content = ( +
+        {JSON.stringify(entry.payload, null, 2)}
+      
+ ); + } + + return ( + <> + + + {showDetail && ( + 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 ( + + {event} + + ); +} diff --git a/ui/web/src/pages/teams/event-sections/event-detail-dialog.tsx b/ui/web/src/pages/teams/event-sections/event-detail-dialog.tsx new file mode 100644 index 00000000..09c21386 --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/event-detail-dialog.tsx @@ -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 ( + !open && onClose()}> + + + + + {entry.event} + + + {new Date(entry.timestamp).toLocaleString()} + + + + +
+ +
+            
+              
+            
+          
+
+ + +
+
+ ); +} + +/** 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 ( + + {part} + + ); + } + // Colon separator + if (part === ":") { + return ( + + {part} + + ); + } + // String value + if (part.startsWith('"')) { + return ( + + {part} + + ); + } + // Boolean / null + if (part === "true" || part === "false" || part === "null") { + return ( + + {part} + + ); + } + // Number + if (/^-?\d/.test(part)) { + return ( + + {part} + + ); + } + // Punctuation / whitespace + return {part}; + })} + + ); +} diff --git a/ui/web/src/pages/teams/event-sections/index.ts b/ui/web/src/pages/teams/event-sections/index.ts new file mode 100644 index 00000000..64c8d9ab --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/index.ts @@ -0,0 +1 @@ +export { EventCard } from "./event-card"; diff --git a/ui/web/src/pages/teams/event-sections/message-event-card.tsx b/ui/web/src/pages/teams/event-sections/message-event-card.tsx new file mode 100644 index 00000000..f99020df --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/message-event-card.tsx @@ -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 ( +
+
+ {from} + → + {to} + + {p.message_type} + +
+ {p.preview && ( +

{p.preview}

+ )} +
+ ); +} diff --git a/ui/web/src/pages/teams/event-sections/task-event-cards.tsx b/ui/web/src/pages/teams/event-sections/task-event-cards.tsx new file mode 100644 index 00000000..4cea4adb --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/task-event-cards.tsx @@ -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 ( +
+
+ {p.subject && {p.subject}} + + {p.status} + +
+
+ {p.owner_agent_key && Owner: {owner}} + {p.reason && Reason: {p.reason}} +
+
+ ); +} diff --git a/ui/web/src/pages/teams/event-sections/team-crud-event-cards.tsx b/ui/web/src/pages/teams/event-sections/team-crud-event-cards.tsx new file mode 100644 index 00000000..d13fc963 --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/team-crud-event-cards.tsx @@ -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 ; + case "team.updated": + return ; + case "team.deleted": + return ; + case "team.member.added": + return ; + case "team.member.removed": + return ; + case "agent_link.created": + return ; + case "agent_link.updated": + return ; + case "agent_link.deleted": + return ; + default: + return
{JSON.stringify(entry.payload, null, 2)}
; + } +} + +type R = { resolveAgent: (keyOrId: string | undefined) => string }; + +function TeamCreatedCard({ payload: p, resolveAgent }: { payload: TeamCreatedPayload } & R) { + return ( +
+ Team + {p.team_name} + created + {p.lead_agent_key && ( + + {" "}(lead: {p.lead_display_name || resolveAgent(p.lead_agent_key)}) + + )} + {p.member_count} member(s) +
+ ); +} + +function TeamUpdatedCard({ payload: p }: { payload: TeamUpdatedPayload }) { + return ( +
+ Team + {p.team_name} + updated + {p.changes.length > 0 && ( + ({p.changes.join(", ")}) + )} +
+ ); +} + +function TeamDeletedCard({ payload: p }: { payload: TeamDeletedPayload }) { + return ( +
+ Team + {p.team_name} + deleted +
+ ); +} + +function MemberAddedCard({ payload: p, resolveAgent }: { payload: TeamMemberAddedPayload } & R) { + return ( +
+ {p.display_name || resolveAgent(p.agent_key)} + added to + {p.team_name} + {p.role} +
+ ); +} + +function MemberRemovedCard({ payload: p, resolveAgent }: { payload: TeamMemberRemovedPayload } & R) { + return ( +
+ {p.display_name || resolveAgent(p.agent_key)} + removed from + {p.team_name} +
+ ); +} + +function LinkCreatedCard({ payload: p, resolveAgent }: { payload: AgentLinkCreatedPayload } & R) { + return ( +
+ {resolveAgent(p.source_agent_key)} + → + {resolveAgent(p.target_agent_key)} + {p.direction} + {p.status} +
+ ); +} + +function LinkUpdatedCard({ payload: p, resolveAgent }: { payload: AgentLinkUpdatedPayload } & R) { + return ( +
+ Link + + {resolveAgent(p.source_agent_key)} → {resolveAgent(p.target_agent_key)} + + {p.changes.length > 0 && ( + ({p.changes.join(", ")}) + )} +
+ ); +} + +function LinkDeletedCard({ payload: p, resolveAgent }: { payload: AgentLinkDeletedPayload } & R) { + return ( +
+ Link + + {resolveAgent(p.source_agent_key)} → {resolveAgent(p.target_agent_key)} + + deleted +
+ ); +} diff --git a/ui/web/src/pages/teams/event-sections/use-agent-resolver.ts b/ui/web/src/pages/teams/event-sections/use-agent-resolver.ts new file mode 100644 index 00000000..44e6124b --- /dev/null +++ b/ui/web/src/pages/teams/event-sections/use-agent-resolver.ts @@ -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(queryKeys.agents.all); + + const { byKey, byId } = useMemo(() => { + const byKey = new Map(); + const byId = new Map(); + 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 }; +} diff --git a/ui/web/src/pages/teams/team-detail-page.tsx b/ui/web/src/pages/teams/team-detail-page.tsx index 30f9cb2f..82bcbf4f 100644 --- a/ui/web/src/pages/teams/team-detail-page.tsx +++ b/ui/web/src/pages/teams/team-detail-page.tsx @@ -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) { Members Tasks Delegations + Realtime Events Settings
@@ -132,6 +134,10 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) { + + + + diff --git a/ui/web/src/pages/teams/team-events-tab.tsx b/ui/web/src/pages/teams/team-events-tab.tsx new file mode 100644 index 00000000..3907c9e6 --- /dev/null +++ b/ui/web/src/pages/teams/team-events-tab.tsx @@ -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(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 ( +
+ {/* Header — status + controls */} +
+
+ + {paused ? "Paused" : "Live"} + + + {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} + {teamEvents.length !== filteredEvents.length && ( + / {teamEvents.length} total + )} + +
+
+ + +
+
+ + {/* Category filter pills */} +
+ {EVENT_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Event feed */} + {filteredEvents.length === 0 ? ( +
+ +
+ ) : ( +
+
+ {filteredEvents.map((entry) => ( + + ))} +
+ + {/* Scroll-to-bottom FAB — shown when user scrolled up */} + {!isAtBottom && ( + + )} +
+ )} +
+ ); +} diff --git a/ui/web/src/stores/use-team-event-store.ts b/ui/web/src/stores/use-team-event-store.ts new file mode 100644 index 00000000..867090e8 --- /dev/null +++ b/ui/web/src/stores/use-team-event-store.ts @@ -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; + 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((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 }), +})); diff --git a/ui/web/src/types/team-events.ts b/ui/web/src/types/team-events.ts new file mode 100644 index 00000000..cf6070cd --- /dev/null +++ b/ui/web/src/types/team-events.ts @@ -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; +}