- MCP create-backlog-item for natural language Plan/Epic/Task authoring - Worker non-code: optional worktree + per-harness allowlist + doc_review→published - Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test - Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI - Fleet: /fleet rollup across workspaces + SSE /api/fleet/events Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md Convoy: .convoys/second-brain-os-v01 Co-authored-by: orchestrator
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
type FleetEvent = {
|
|
id: string;
|
|
workspaceId: string;
|
|
convoyId: string;
|
|
briefId: string | null;
|
|
role: string;
|
|
actor: string | null;
|
|
eventType: string;
|
|
harnessAlias: string | null;
|
|
occurredAt: string;
|
|
metadata: Record<string, unknown> | null;
|
|
classification?: string | null;
|
|
durationS?: number | null;
|
|
outcome?: string | null;
|
|
};
|
|
|
|
function formatRelative(iso: string): string {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
const s = Math.floor(diff / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
const m = Math.floor(s / 60);
|
|
if (m < 60) return `${m}m ago`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${Math.floor(h / 24)}d ago`;
|
|
}
|
|
|
|
export function FleetActivityFeed() {
|
|
const [events, setEvents] = React.useState<FleetEvent[]>([]);
|
|
const [connected, setConnected] = React.useState(false);
|
|
const seenRef = React.useRef<Set<string>>(new Set());
|
|
|
|
React.useEffect(() => {
|
|
const es = new EventSource("/api/fleet/events");
|
|
|
|
es.onopen = () => setConnected(true);
|
|
es.onerror = () => setConnected(false);
|
|
|
|
es.onmessage = (e) => {
|
|
try {
|
|
const parsed = JSON.parse(e.data) as FleetEvent | FleetEvent[];
|
|
const incoming = Array.isArray(parsed) ? parsed : [parsed];
|
|
// Filter duplicates by id
|
|
const fresh = incoming.filter((ev) => {
|
|
if (!ev?.id || seenRef.current.has(ev.id)) return false;
|
|
seenRef.current.add(ev.id);
|
|
return true;
|
|
});
|
|
if (fresh.length === 0) return;
|
|
setEvents((prev) => {
|
|
const merged = [...fresh, ...prev];
|
|
// keep sort by occurredAt desc, dedup already handled, limit 50
|
|
merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
|
|
return merged.slice(0, 100);
|
|
});
|
|
} catch {
|
|
// ignore malformed
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
es.close();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-card">
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
<h3 className="text-sm font-semibold">Activity feed</h3>
|
|
<span className="text-[10px] text-muted-foreground">{connected ? "live" : "reconnecting…"}</span>
|
|
</div>
|
|
<div className="max-h-[360px] overflow-y-auto">
|
|
{events.length === 0 ? (
|
|
<p className="p-4 text-sm text-muted-foreground">No recent convoy events.</p>
|
|
) : (
|
|
<ul className="divide-y divide-border">
|
|
{events.map((ev) => (
|
|
<li key={ev.id} className="px-4 py-2.5 text-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="font-medium">{ev.eventType}</span>
|
|
<span className="text-xs text-muted-foreground">{formatRelative(ev.occurredAt)}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{ev.role}
|
|
{ev.actor && ev.actor !== ev.role ? ` · ${ev.actor}` : ""}
|
|
{ev.harnessAlias ? ` · ${ev.harnessAlias}` : ""}
|
|
{ev.outcome ? ` · ${ev.outcome}` : ""}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">convoy {ev.convoyId.slice(0, 8)}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|