Some checks failed
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Failing after 1m24s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Failing after 37s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m0s
Stage 3 of #61 (final stage). Replaces the 5s setInterval poll with: 1. Initial bootstrap on mount + on filter-change + on workspaceId- change (preserved from existing useEffect on loadActivities). 2. Manual Refresh button (preserved — still triggers loadActivities). 3. useSocketEvent subscription to ACTIVITY_LOGGED — every event for THIS workspace prepends to the list, gated on the user's autoRefresh toggle and current filter selection. No interval poll. Steady-state HTTP traffic from this tab drops from 12 req/min (5s × 1 active workspace) to 0 outside of bootstraps and manual refreshes. Live update latency drops from up to 5s to ~10ms. The autoRefresh ("Live" / "Paused") toggle now gates LIVE updates instead of polling cadence — semantically the same (paused = list stays frozen), implementationally simpler. The filter selection is honoured by the WS handler so a user filtering to "Tasks" doesn't see live a2a_send rows trickle in. Same shape the server-side `?type=<filter>` enforces on the bootstrap. Test changes: - 27 existing tests pass unchanged (filter / autoRefresh / Refresh / loading / error / empty / count / row-content all preserved) - 7 new WS-subscription tests: - WS push for matching workspace prepends with NO HTTP call - WS push for different workspace ignored - WS push respects active filter (non-matching ignored) - WS push respects active filter (matching renders) - WS push while autoRefresh paused ignored - WS push for already-in-list row deduped (no double-render) - NO 5s interval polling after mount Mutation-tested: - drop workspace_id filter → "different workspace" test fails - drop autoRefresh gate → "paused" test fails - drop filter gate → "non-matching activity_type" test fails - drop dedup-by-id → "already in list deduped" test fails Full canvas suite: 1396 passing, 0 failing. tsc clean. No API or schema change. /workspaces/:id/activity HTTP endpoint stays — used for bootstrap + manual refresh + filter-change reload. ACTIVITY_LOGGED event shape unchanged. Hostile self-review (three weakest spots): 1. Server-side activity_logs row UPDATES (status flips, etc.) are not reflected post-#61 — the dedup-by-id check skips a re-fired ACTIVITY_LOGGED for an existing row. Acceptable: activity_logs is append-only by design (audit trail); status updates surface as new task_update rows, not as in-place mutations. If a future server change adds in-place updates, fire ACTIVITY_UPDATED as a distinct event so this dedup logic stays simple. 2. WS handler is recreated on every render (filter / autoRefresh / workspaceId state changes). useSocketEvent's ref-based pattern keeps the bus subscription stable, but the handler closure re-captures each render. Side effect: fine — handler call cost is negligible. 3. The "error" filter matches activity_type === "error" (mirrors server semantics). It does NOT match status === "error" rows of other activity types — same as the polling version. Worth re-evaluating in a separate PR if users expect the broader semantic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { ConversationTraceModal } from "@/components/ConversationTraceModal";
|
|
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
|
import { type ActivityEntry } from "@/types/activity";
|
|
import { useWorkspaceName } from "@/hooks/useWorkspaceName";
|
|
import { inferA2AErrorHint } from "./chat/a2aErrorHint";
|
|
|
|
interface Props {
|
|
workspaceId: string;
|
|
}
|
|
|
|
type FilterType = "all" | "a2a_receive" | "a2a_send" | "task_update" | "agent_log" | "skill_promotion" | "error";
|
|
|
|
const FILTERS: { id: FilterType; label: string; icon: string }[] = [
|
|
{ id: "all", label: "All", icon: "●" },
|
|
{ id: "a2a_receive", label: "A2A In", icon: "↙" },
|
|
{ id: "a2a_send", label: "A2A Out", icon: "↗" },
|
|
{ id: "task_update", label: "Tasks", icon: "◆" },
|
|
{ id: "skill_promotion", label: "Skill Promo", icon: "★" },
|
|
{ id: "agent_log", label: "Logs", icon: "▸" },
|
|
{ id: "error", label: "Errors", icon: "!" },
|
|
];
|
|
|
|
const TYPE_COLORS: Record<string, { text: string; bg: string; border: string }> = {
|
|
a2a_receive: { text: "text-accent", bg: "bg-blue-950/30", border: "border-blue-800/30" },
|
|
a2a_send: { text: "text-cyan-400", bg: "bg-cyan-950/30", border: "border-cyan-800/30" },
|
|
task_update: { text: "text-warm", bg: "bg-amber-950/30", border: "border-amber-800/30" },
|
|
skill_promotion: { text: "text-violet-300", bg: "bg-violet-950/30", border: "border-violet-800/30" },
|
|
agent_log: { text: "text-ink-mid", bg: "bg-surface-card/30", border: "border-line/30" },
|
|
error: { text: "text-bad", bg: "bg-red-950/30", border: "border-red-800/30" },
|
|
};
|
|
|
|
const STATUS_ICONS: Record<string, { icon: string; color: string }> = {
|
|
ok: { icon: "✓", color: "text-good" },
|
|
error: { icon: "✕", color: "text-bad" },
|
|
timeout: { icon: "⏱", color: "text-warm" },
|
|
};
|
|
|
|
export function ActivityTab({ workspaceId }: Props) {
|
|
const [activities, setActivities] = useState<ActivityEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [filter, setFilter] = useState<FilterType>("all");
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
const [traceOpen, setTraceOpen] = useState(false);
|
|
const resolveName = useWorkspaceName();
|
|
|
|
// Refs let the WS handler read the latest filter / autoRefresh
|
|
// selection without re-subscribing on every state change. The bus
|
|
// listener is registered exactly once per mount via useSocketEvent's
|
|
// ref-internal pattern; subscriber-side filtering reads from these.
|
|
const filterRef = useRef(filter);
|
|
filterRef.current = filter;
|
|
const autoRefreshRef = useRef(autoRefresh);
|
|
autoRefreshRef.current = autoRefresh;
|
|
|
|
const loadActivities = useCallback(async () => {
|
|
try {
|
|
const typeParam = filter !== "all" ? `?type=${filter}` : "";
|
|
const data = await api.get<ActivityEntry[]>(`/workspaces/${workspaceId}/activity${typeParam}`);
|
|
setActivities(data);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to load activity");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workspaceId, filter]);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
loadActivities();
|
|
}, [loadActivities]);
|
|
|
|
// Live-update path (issue #61 stage 3, replaces the 5s setInterval).
|
|
// ACTIVITY_LOGGED events from this workspace prepend to the rendered
|
|
// list — dedup by id so a server-side update + a poll reply don't
|
|
// double-render the same row.
|
|
//
|
|
// Honours the user's autoRefresh toggle: when paused, live updates
|
|
// are dropped until the user re-enables Live (or hits Refresh, which
|
|
// re-bootstraps via loadActivities).
|
|
//
|
|
// Filter awareness: matches the server-side `?type=<filter>`
|
|
// semantics so the panel doesn't show rows the user excluded.
|
|
useSocketEvent((msg) => {
|
|
if (!autoRefreshRef.current) return;
|
|
if (msg.event !== "ACTIVITY_LOGGED") return;
|
|
if (msg.workspace_id !== workspaceId) return;
|
|
|
|
const p = (msg.payload || {}) as Record<string, unknown>;
|
|
const activityType = (p.activity_type as string) || "";
|
|
|
|
const f = filterRef.current;
|
|
if (f !== "all" && activityType !== f) return;
|
|
|
|
const entry: ActivityEntry = {
|
|
id:
|
|
(p.id as string) ||
|
|
`ws-push-${msg.timestamp || Date.now()}-${msg.workspace_id}`,
|
|
workspace_id: msg.workspace_id,
|
|
activity_type: activityType,
|
|
source_id: (p.source_id as string | null) ?? null,
|
|
target_id: (p.target_id as string | null) ?? null,
|
|
method: (p.method as string | null) ?? null,
|
|
summary: (p.summary as string | null) ?? null,
|
|
request_body: (p.request_body as Record<string, unknown> | null) ?? null,
|
|
response_body:
|
|
(p.response_body as Record<string, unknown> | null) ?? null,
|
|
duration_ms: (p.duration_ms as number | null) ?? null,
|
|
status: (p.status as string) || "ok",
|
|
error_detail: (p.error_detail as string | null) ?? null,
|
|
created_at:
|
|
(p.created_at as string) ||
|
|
msg.timestamp ||
|
|
new Date().toISOString(),
|
|
};
|
|
|
|
setActivities((prev) => {
|
|
// Dedup by id — a row that arrived via the bootstrap fetch and
|
|
// also fires ACTIVITY_LOGGED from a delayed server-side hook
|
|
// must render exactly once.
|
|
if (prev.some((e) => e.id === entry.id)) return prev;
|
|
return [entry, ...prev];
|
|
});
|
|
});
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Filter bar */}
|
|
<div className="px-3 pt-3 pb-2 border-b border-line/40">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{FILTERS.map((f) => (
|
|
<button
|
|
key={f.id}
|
|
onClick={() => setFilter(f.id)}
|
|
aria-pressed={filter === f.id}
|
|
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
|
filter === f.id
|
|
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
|
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
|
}`}
|
|
>
|
|
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
|
|
</button>
|
|
))}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
|
aria-pressed={autoRefresh}
|
|
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
|
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
|
|
}`}
|
|
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
|
>
|
|
{autoRefresh ? "⟳ Live" : "⟳ Paused"}
|
|
</button>
|
|
<button
|
|
onClick={() => setTraceOpen(true)}
|
|
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
|
|
title="View full conversation trace across all workspaces"
|
|
>
|
|
Full Trace
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={loadActivities}
|
|
// hover:bg-surface-card on top of itself was a no-op;
|
|
// lift to surface-elevated + focus-visible ring.
|
|
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[11px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-1.5 text-[10px] text-ink-soft">
|
|
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Activity list */}
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
|
{loading && activities.length === 0 && (
|
|
<div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && activities.length === 0 && (
|
|
<div className="text-center py-8">
|
|
<div className="text-ink-soft text-xs">No activity recorded yet</div>
|
|
<div className="text-ink-soft text-[9px] mt-1">
|
|
Activity logs appear when agents communicate or perform tasks
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activities.map((entry) => (
|
|
<ActivityRow
|
|
key={entry.id}
|
|
entry={entry}
|
|
expanded={expanded === entry.id}
|
|
onToggle={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
|
resolveName={resolveName}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<ConversationTraceModal
|
|
open={traceOpen}
|
|
workspaceId={workspaceId}
|
|
onClose={() => setTraceOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityRow({
|
|
entry,
|
|
expanded,
|
|
onToggle,
|
|
resolveName,
|
|
}: {
|
|
entry: ActivityEntry;
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
resolveName: (id: string | null) => string;
|
|
}) {
|
|
const typeStyle = TYPE_COLORS[entry.activity_type] || TYPE_COLORS.agent_log;
|
|
const statusStyle = STATUS_ICONS[entry.status] || STATUS_ICONS.ok;
|
|
const isA2A = entry.activity_type.startsWith("a2a_");
|
|
const isError = entry.status === "error";
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-lg border transition-colors ${
|
|
isError
|
|
? "bg-red-950/20 border-red-900/30"
|
|
: "bg-surface-card/60 border-line/40"
|
|
}`}
|
|
>
|
|
<button type="button" onClick={onToggle} className="w-full text-left px-3 py-2">
|
|
{/* Top row: type badge + method + time */}
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-[8px] font-mono px-1.5 py-0.5 rounded ${typeStyle.text} ${typeStyle.bg} border ${typeStyle.border}`}>
|
|
{formatType(entry.activity_type)}
|
|
</span>
|
|
|
|
{entry.method && (
|
|
<span className="text-[10px] font-mono text-ink-mid truncate">
|
|
{entry.method}
|
|
</span>
|
|
)}
|
|
|
|
<span className={`text-[9px] ml-auto shrink-0 ${statusStyle.color}`}>
|
|
{statusStyle.icon}
|
|
</span>
|
|
|
|
{entry.duration_ms != null && (
|
|
<span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
|
|
{entry.duration_ms}ms
|
|
</span>
|
|
)}
|
|
|
|
<span className="text-[8px] text-ink-soft shrink-0">
|
|
{formatTime(entry.created_at)}
|
|
</span>
|
|
|
|
<span className="text-[9px] text-ink-soft">
|
|
{expanded ? "▼" : "▶"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Summary — replace raw IDs with workspace names */}
|
|
{entry.summary && (
|
|
<div className="text-[10px] text-ink-mid mt-1 truncate">
|
|
{entry.summary
|
|
.replace(entry.source_id || "", resolveName(entry.source_id))
|
|
.replace(entry.target_id || "", resolveName(entry.target_id))}
|
|
</div>
|
|
)}
|
|
|
|
{/* A2A flow indicator */}
|
|
{isA2A && (entry.source_id || entry.target_id) && (
|
|
<div className="flex items-center gap-1 mt-1">
|
|
{entry.source_id && (
|
|
<span className="text-[9px] text-cyan-400/80 truncate max-w-[140px]" title={entry.source_id}>
|
|
{resolveName(entry.source_id)}
|
|
</span>
|
|
)}
|
|
<span className="text-[9px] text-ink-soft">→</span>
|
|
{entry.target_id && (
|
|
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
|
|
{resolveName(entry.target_id)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Error detail */}
|
|
{isError && entry.error_detail && (
|
|
<div className="text-[9px] text-bad/80 mt-1 truncate">
|
|
{entry.error_detail}
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded details */}
|
|
{expanded && (
|
|
<div className="px-3 pb-3 space-y-2 border-t border-line/30 mt-1 pt-2">
|
|
{entry.source_id && (
|
|
<Detail label="Source" value={`${resolveName(entry.source_id)} (${entry.source_id.slice(0, 8)})`} />
|
|
)}
|
|
{entry.target_id && (
|
|
<Detail label="Target" value={`${resolveName(entry.target_id)} (${entry.target_id.slice(0, 8)})`} />
|
|
)}
|
|
{/* Message preview — extract text from A2A request/response */}
|
|
{entry.request_body && (
|
|
<MessagePreview label="Message Sent" body={entry.request_body} />
|
|
)}
|
|
{entry.response_body && (
|
|
<MessagePreview label="Response" body={entry.response_body} />
|
|
)}
|
|
{entry.error_detail && (
|
|
<Detail label="Error" value={entry.error_detail} error />
|
|
)}
|
|
{entry.request_body && (
|
|
<JsonBlock label="Raw Request" data={entry.request_body} />
|
|
)}
|
|
{entry.response_body && (
|
|
<JsonBlock label="Response" data={entry.response_body} />
|
|
)}
|
|
<div className="text-[8px] text-ink-soft font-mono select-all">
|
|
ID: {entry.id}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const A2A_ERROR_PREFIX = "[A2A_ERROR]";
|
|
|
|
/** Render a [A2A_ERROR]-prefixed response as a structured error block
|
|
* with a stripped detail line + a cause hint. The previous raw render
|
|
* ("[A2A_ERROR] " literal in the response area) gave the user no
|
|
* signal to act on. */
|
|
function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
|
|
const detail = raw.slice(A2A_ERROR_PREFIX.length).trim() || "(no detail provided)";
|
|
const hint = inferA2AErrorHint(detail);
|
|
return (
|
|
<div>
|
|
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} — delivery failed</div>
|
|
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
|
|
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
|
|
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Extract human-readable text from A2A request/response JSON */
|
|
function MessagePreview({ label, body }: { label: string; body: Record<string, unknown> }) {
|
|
// Try to extract text from A2A message parts
|
|
let text = "";
|
|
try {
|
|
// Simple formats from MCP server: {task: "..."} or {result: "..."}
|
|
if (body.task && typeof body.task === "string") { text = body.task; }
|
|
if (!text && body.result && typeof body.result === "string") { text = body.result; }
|
|
if (text) {
|
|
// [A2A_ERROR]-prefixed responses get the structured error
|
|
// treatment. Bare text fallthrough renders a bland gray block
|
|
// — fine for normal replies, terrible for "[A2A_ERROR] " with
|
|
// no further context. Detect at the top of the rendering path
|
|
// so it short-circuits before the generic preview kicks in.
|
|
if (text.trimStart().startsWith(A2A_ERROR_PREFIX)) {
|
|
return <A2AErrorPreview label={label} raw={text.trimStart()} />;
|
|
}
|
|
return (
|
|
<div>
|
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
|
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
|
{text.slice(0, 2000)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Request: params.message.parts[].text
|
|
const params = body.params as Record<string, unknown> | undefined;
|
|
const message = params?.message as Record<string, unknown> | undefined;
|
|
const parts = (message?.parts || []) as Array<Record<string, unknown>>;
|
|
text = parts
|
|
.map((p) => (p.text as string) || (p.kind === "text" ? (p.text as string) : ""))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
// Response: result.parts[].text
|
|
if (!text) {
|
|
const result = body.result as Record<string, unknown> | undefined;
|
|
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
|
text = rParts
|
|
.map((p) => {
|
|
if (p.text) return p.text as string;
|
|
const root = p.root as Record<string, unknown> | undefined;
|
|
return (root?.text as string) || "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
// Fallback: result as string
|
|
if (!text && typeof body.result === "string") {
|
|
text = body.result;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (!text) return null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
|
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
|
|
{text.slice(0, 2000)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
|
|
return (
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
|
|
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
|
|
return (
|
|
<div>
|
|
<div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
|
|
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
|
{JSON.stringify(data, null, 2)}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatType(type: string): string {
|
|
switch (type) {
|
|
case "a2a_receive": return "A2A IN";
|
|
case "a2a_send": return "A2A OUT";
|
|
case "task_update": return "TASK";
|
|
case "skill_promotion": return "PROMO";
|
|
case "agent_log": return "LOG";
|
|
case "error": return "ERROR";
|
|
default: return type.toUpperCase();
|
|
}
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const diff = now.getTime() - d.getTime();
|
|
|
|
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
|
|
return d.toLocaleDateString();
|
|
}
|