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; +}