mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
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:
1 parent
f426460358
commit
9b9e99e72a
18 files changed
+1159
-4
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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">→</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">→</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">→</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)} → {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)} → {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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user