feat: add Mission Control tab with live agent visualization
Implement the Mission Control dashboard for real-time visualization of parallel agent activity, inspired by Gource. The feature includes three panels: 1. Agent Map (left): D3 force-directed visualization showing agents as orbiting nodes around a central "main" node, with files they touch as leaf nodes that pulse on modification. 2. Branch Timeline (center): Horizontal swimlanes per agent showing commits as nodes, with visual indication of how far each branch has diverged from main. 3. Activity Feed (right): Real-time scrolling feed of agent actions via SSE, showing commits, file changes, and PR events as they happen. Technical implementation: - GitActivityPoller service polls git log on worktrees every 10 seconds - New /api/activity SSE endpoint streams events to clients - D3-force for physics simulation, Canvas for rendering - Event buffering to handle rapid-fire git activity Closes #81 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5fe476774d
commit
2c269d9174
|
|
@ -28,6 +28,7 @@
|
|||
"@composio/ao-plugin-workspace-worktree": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"d3-force": "^3.0.0",
|
||||
"next": "^15.1.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^19.0.0",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { AgentMap } from "@/components/activity/AgentMap";
|
||||
import { BranchTimeline } from "@/components/activity/BranchTimeline";
|
||||
import { ActivityFeed } from "@/components/activity/ActivityFeed";
|
||||
import type { ActivityEvent, SessionGitActivity } from "@/lib/git-activity";
|
||||
|
||||
interface SessionSnapshot {
|
||||
id: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
activity: string;
|
||||
pr: {
|
||||
number: number;
|
||||
url: string;
|
||||
state: string;
|
||||
ciStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ActivityState {
|
||||
sessions: SessionSnapshot[];
|
||||
gitActivity: Record<string, SessionGitActivity>;
|
||||
events: ActivityEvent[];
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [state, setState] = useState<ActivityState>({
|
||||
sessions: [],
|
||||
gitActivity: {},
|
||||
events: [],
|
||||
});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const connectToSSE = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const es = new EventSource("/api/activity");
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "snapshot") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sessions: data.sessions,
|
||||
gitActivity: data.gitActivity ?? {},
|
||||
}));
|
||||
} else if (data.type === "activity") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
events: [data.event, ...prev.events].slice(0, 100), // Keep last 100 events
|
||||
}));
|
||||
} else if (data.type === "state_update") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sessions: data.sessions,
|
||||
gitActivity: {
|
||||
...prev.gitActivity,
|
||||
...data.gitActivity,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setConnected(false);
|
||||
es.close();
|
||||
// Reconnect after 3 seconds
|
||||
setTimeout(connectToSSE, 3000);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connectToSSE();
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connectToSSE]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-[var(--color-bg-primary)]">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b border-[var(--color-border-default)] px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
className="text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
<span className="text-[#7c8aff]">Mission</span> Control
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${connected ? "bg-[var(--color-accent-green)]" : "bg-[var(--color-accent-red)]"}`}
|
||||
/>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{connected ? "Live" : "Reconnecting..."}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{state.sessions.length} agent{state.sessions.length !== 1 ? "s" : ""} active
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content — three-panel layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel — Agent Map (Gource-inspired visualization) */}
|
||||
<div className="w-[380px] border-r border-[var(--color-border-default)] p-4">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Agent Map
|
||||
</h2>
|
||||
<AgentMap
|
||||
sessions={state.sessions}
|
||||
gitActivity={state.gitActivity}
|
||||
events={state.events}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center panel — Branch Timeline */}
|
||||
<div className="flex-1 overflow-auto border-r border-[var(--color-border-default)] p-4">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Branch Timeline
|
||||
</h2>
|
||||
<BranchTimeline sessions={state.sessions} gitActivity={state.gitActivity} />
|
||||
</div>
|
||||
|
||||
{/* Right panel — Activity Feed */}
|
||||
<div className="flex w-[360px] flex-col p-4">
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Live Activity
|
||||
</h2>
|
||||
<ActivityFeed events={state.events} sessions={state.sessions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
import {
|
||||
pollSessionsActivity,
|
||||
onActivity,
|
||||
type ActivityEvent,
|
||||
type SessionGitActivity,
|
||||
} from "@/lib/git-activity";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/activity — SSE stream for Mission Control activity events
|
||||
*
|
||||
* Sends:
|
||||
* - Initial snapshot of all session git activity
|
||||
* - Real-time commit/file change events as they happen
|
||||
* - Session state updates (PR opened, CI status, etc.)
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
const encoder = new TextEncoder();
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
let poller: ReturnType<typeof setInterval> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Buffer for events to send
|
||||
const eventBuffer: ActivityEvent[] = [];
|
||||
let isSendingInitial = true;
|
||||
|
||||
// Subscribe to real-time activity events
|
||||
unsubscribe = onActivity((event) => {
|
||||
if (isSendingInitial) {
|
||||
eventBuffer.push(event);
|
||||
} else {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "activity", event })}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
// Stream closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial snapshot
|
||||
void (async () => {
|
||||
try {
|
||||
const { config, sessionManager } = await getServices();
|
||||
const sessions = await sessionManager.list();
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => !["done", "terminated", "killed"].includes(s.status),
|
||||
);
|
||||
const dashboardSessions = activeSessions.map(sessionToDashboard);
|
||||
|
||||
// Get default branch from first project config
|
||||
const firstProject = Object.values(config.projects)[0];
|
||||
const defaultBranch = firstProject?.defaultBranch ?? "main";
|
||||
|
||||
// Poll initial activity
|
||||
const activity = await pollSessionsActivity(dashboardSessions, defaultBranch);
|
||||
|
||||
// Convert to serializable format
|
||||
const activitySnapshot: Record<string, SessionGitActivity> = {};
|
||||
for (const [id, act] of activity) {
|
||||
activitySnapshot[id] = {
|
||||
...act,
|
||||
lastPolledAt: act.lastPolledAt,
|
||||
commits: act.commits.map((c) => ({
|
||||
...c,
|
||||
timestamp: c.timestamp,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Send initial snapshot
|
||||
const initialEvent = {
|
||||
type: "snapshot",
|
||||
sessions: dashboardSessions.map((s) => ({
|
||||
id: s.id,
|
||||
branch: s.branch,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
pr: s.pr
|
||||
? {
|
||||
number: s.pr.number,
|
||||
url: s.pr.url,
|
||||
state: s.pr.state,
|
||||
ciStatus: s.pr.ciStatus,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
gitActivity: activitySnapshot,
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
|
||||
|
||||
// Send any buffered events
|
||||
isSendingInitial = false;
|
||||
for (const event of eventBuffer) {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "activity", event })}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
eventBuffer.length = 0;
|
||||
} catch {
|
||||
// If services aren't available, send empty snapshot
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "snapshot", sessions: [], gitActivity: {} })}\n\n`,
|
||||
),
|
||||
);
|
||||
isSendingInitial = false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Send periodic heartbeat
|
||||
heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(poller);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Poll for git activity every 10 seconds
|
||||
poller = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { config, sessionManager } = await getServices();
|
||||
const sessions = await sessionManager.list();
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => !["done", "terminated", "killed"].includes(s.status),
|
||||
);
|
||||
const dashboardSessions = activeSessions.map(sessionToDashboard);
|
||||
|
||||
const firstProject = Object.values(config.projects)[0];
|
||||
const defaultBranch = firstProject?.defaultBranch ?? "main";
|
||||
|
||||
// Poll activity — this will emit events via the subscription
|
||||
const activity = await pollSessionsActivity(dashboardSessions, defaultBranch);
|
||||
|
||||
// Send session state update
|
||||
const stateUpdate = {
|
||||
type: "state_update",
|
||||
sessions: dashboardSessions.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
pr: s.pr
|
||||
? {
|
||||
number: s.pr.number,
|
||||
state: s.pr.state,
|
||||
ciStatus: s.pr.ciStatus,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
gitActivity: Object.fromEntries(
|
||||
Array.from(activity.entries()).map(([id, act]) => [
|
||||
id,
|
||||
{
|
||||
commitsAhead: act.commitsAhead,
|
||||
recentFiles: act.recentFiles.slice(0, 10),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stateUpdate)}\n\n`));
|
||||
} catch {
|
||||
// Transient error — skip this poll
|
||||
}
|
||||
})();
|
||||
}, 10000);
|
||||
},
|
||||
cancel() {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(poller);
|
||||
if (unsubscribe) unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -88,6 +88,12 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) {
|
|||
<span className="text-[#7c8aff]">Agent</span> Orchestrator
|
||||
</h1>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<a
|
||||
href="/activity"
|
||||
className="rounded-md border border-[var(--color-border-default)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-purple)] hover:text-[var(--color-accent-purple)]"
|
||||
>
|
||||
mission control
|
||||
</a>
|
||||
{orchestratorId && (
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { ActivityEvent } from "@/lib/git-activity";
|
||||
|
||||
interface SessionSnapshot {
|
||||
id: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
activity: string;
|
||||
pr: {
|
||||
number: number;
|
||||
state: string;
|
||||
ciStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ActivityFeedProps {
|
||||
events: ActivityEvent[];
|
||||
sessions: SessionSnapshot[];
|
||||
}
|
||||
|
||||
/** Get a color for a session based on its ID (consistent hashing) */
|
||||
function getSessionColor(sessionId: string): string {
|
||||
const colors = [
|
||||
"#58a6ff", // blue
|
||||
"#3fb950", // green
|
||||
"#a371f7", // purple
|
||||
"#f0883e", // orange
|
||||
"#f778ba", // pink
|
||||
"#79c0ff", // light blue
|
||||
"#7ee787", // light green
|
||||
"#d2a8ff", // light purple
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < sessionId.length; i++) {
|
||||
hash = (hash << 5) - hash + sessionId.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/** Format relative time */
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const now = new Date();
|
||||
const then = typeof date === "string" ? new Date(date) : date;
|
||||
const diffMs = now.getTime() - then.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSecs < 5) return "just now";
|
||||
if (diffSecs < 60) return `${diffSecs}s ago`;
|
||||
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
|
||||
/** Get icon for event type */
|
||||
function getEventIcon(type: ActivityEvent["type"]): string {
|
||||
switch (type) {
|
||||
case "commit":
|
||||
return "M10 20a10 10 0 1 1 0-20 10 10 0 0 1 0 20zm-2-10a2 2 0 1 0 4 0 2 2 0 0 0-4 0z";
|
||||
case "push":
|
||||
return "M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z";
|
||||
case "file_change":
|
||||
return "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 12h8v2H8v-2zm0 4h8v2H8v-2z";
|
||||
case "pr_opened":
|
||||
return "M6 3a2 2 0 0 0-2 2v1.75a.75.75 0 0 0 1.5 0V5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5v-1.75a.75.75 0 0 0-1.5 0V16a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H6zm7.78 4.22a.75.75 0 0 1 0 1.06l-1.97 1.97h6.44a.75.75 0 0 1 0 1.5h-6.44l1.97 1.97a.75.75 0 1 1-1.06 1.06l-3.25-3.25a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0z";
|
||||
case "ci_status":
|
||||
return "M10 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm-1-7.586l-2.293-2.293-1.414 1.414L9 13.414l5.707-5.707-1.414-1.414L9 10.586z";
|
||||
case "review":
|
||||
return "M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z";
|
||||
default:
|
||||
return "M10 20a10 10 0 1 1 0-20 10 10 0 0 1 0 20z";
|
||||
}
|
||||
}
|
||||
|
||||
export function ActivityFeed({ events, sessions }: ActivityFeedProps) {
|
||||
// Generate synthetic events from session state if no real events yet
|
||||
const displayEvents = useMemo(() => {
|
||||
if (events.length > 0) return events;
|
||||
|
||||
// Create placeholder events from session states
|
||||
return sessions.map((session) => ({
|
||||
id: `synthetic-${session.id}`,
|
||||
type: "commit" as const,
|
||||
sessionId: session.id,
|
||||
branch: session.branch ?? "unknown",
|
||||
message: session.pr
|
||||
? `PR #${session.pr.number} ${session.pr.state}`
|
||||
: `Working on ${session.branch ?? "branch"}`,
|
||||
timestamp: new Date(),
|
||||
data: {},
|
||||
}));
|
||||
}, [events, sessions]);
|
||||
|
||||
if (displayEvents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-[var(--color-text-muted)]">
|
||||
<svg className="mx-auto h-12 w-12 opacity-50" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Waiting for activity...</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] opacity-60">
|
||||
Events will appear here as agents work
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{displayEvents.map((event) => (
|
||||
<ActivityEventRow key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityEventRow({ event }: { event: ActivityEvent }) {
|
||||
const color = getSessionColor(event.sessionId);
|
||||
|
||||
return (
|
||||
<div className="group flex gap-3 rounded-md px-2 py-2 transition-colors hover:bg-[var(--color-bg-secondary)]">
|
||||
{/* Session indicator */}
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20" style={{ color }}>
|
||||
<path d={getEventIcon(event.type)} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-semibold" style={{ color }}>
|
||||
{event.sessionId}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{formatRelativeTime(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-sm text-[var(--color-text-secondary)]">
|
||||
{event.message}
|
||||
</p>
|
||||
{event.data.files && event.data.files.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{event.data.files.slice(0, 3).map((file) => (
|
||||
<span
|
||||
key={file}
|
||||
className="rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
{file.split("/").pop()}
|
||||
</span>
|
||||
))}
|
||||
{event.data.files.length > 3 && (
|
||||
<span className="rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
+{event.data.files.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useMemo, useState } from "react";
|
||||
import {
|
||||
forceSimulation,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
type Simulation,
|
||||
type SimulationNodeDatum,
|
||||
type SimulationLinkDatum,
|
||||
} from "d3-force";
|
||||
import type { ActivityEvent, SessionGitActivity } from "@/lib/git-activity";
|
||||
|
||||
interface SessionSnapshot {
|
||||
id: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
activity: string;
|
||||
pr: {
|
||||
number: number;
|
||||
state: string;
|
||||
ciStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AgentMapProps {
|
||||
sessions: SessionSnapshot[];
|
||||
gitActivity: Record<string, SessionGitActivity>;
|
||||
events: ActivityEvent[];
|
||||
}
|
||||
|
||||
interface Node extends SimulationNodeDatum {
|
||||
id: string;
|
||||
type: "main" | "agent" | "file";
|
||||
label: string;
|
||||
color: string;
|
||||
radius: number;
|
||||
pulse?: boolean;
|
||||
lastPulse?: number;
|
||||
}
|
||||
|
||||
interface Link extends SimulationLinkDatum<Node> {
|
||||
source: string | Node;
|
||||
target: string | Node;
|
||||
type: "branch" | "file";
|
||||
strength: number;
|
||||
pulse?: boolean;
|
||||
lastPulse?: number;
|
||||
}
|
||||
|
||||
/** Get a color for a session based on its ID (consistent hashing) */
|
||||
function getSessionColor(sessionId: string): string {
|
||||
const colors = [
|
||||
"#58a6ff", // blue
|
||||
"#3fb950", // green
|
||||
"#a371f7", // purple
|
||||
"#f0883e", // orange
|
||||
"#f778ba", // pink
|
||||
"#79c0ff", // light blue
|
||||
"#7ee787", // light green
|
||||
"#d2a8ff", // light purple
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < sessionId.length; i++) {
|
||||
hash = (hash << 5) - hash + sessionId.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/** Get file color based on extension */
|
||||
function getFileColor(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "ts":
|
||||
case "tsx":
|
||||
return "#3178c6";
|
||||
case "js":
|
||||
case "jsx":
|
||||
return "#f7df1e";
|
||||
case "css":
|
||||
case "scss":
|
||||
return "#264de4";
|
||||
case "json":
|
||||
return "#cbcb41";
|
||||
case "md":
|
||||
return "#083fa1";
|
||||
case "yaml":
|
||||
case "yml":
|
||||
return "#cb171e";
|
||||
default:
|
||||
return "#6e7681";
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentMap({ sessions, gitActivity, events }: AgentMapProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const simulationRef = useRef<Simulation<Node, Link> | null>(null);
|
||||
const nodesRef = useRef<Node[]>([]);
|
||||
const linksRef = useRef<Link[]>([]);
|
||||
const animationRef = useRef<number>(0);
|
||||
const [dimensions, setDimensions] = useState({ width: 340, height: 400 });
|
||||
|
||||
// Build graph data from sessions and activity
|
||||
const graphData = useMemo(() => {
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const fileMap = new Map<string, Set<string>>(); // file -> agent IDs
|
||||
|
||||
// Central "main" node
|
||||
nodes.push({
|
||||
id: "main",
|
||||
type: "main",
|
||||
label: "main",
|
||||
color: "#484f58",
|
||||
radius: 20,
|
||||
});
|
||||
|
||||
// Agent nodes
|
||||
for (const session of sessions) {
|
||||
const color = getSessionColor(session.id);
|
||||
const activity = gitActivity[session.id];
|
||||
const isActive = session.activity === "active";
|
||||
|
||||
nodes.push({
|
||||
id: session.id,
|
||||
type: "agent",
|
||||
label: session.id,
|
||||
color,
|
||||
radius: isActive ? 14 : 10,
|
||||
pulse: isActive,
|
||||
});
|
||||
|
||||
// Link agent to main
|
||||
links.push({
|
||||
source: "main",
|
||||
target: session.id,
|
||||
type: "branch",
|
||||
strength: 0.8,
|
||||
});
|
||||
|
||||
// Track files touched by this agent
|
||||
if (activity?.recentFiles) {
|
||||
for (const file of activity.recentFiles.slice(0, 5)) {
|
||||
const existing = fileMap.get(file);
|
||||
if (existing) {
|
||||
existing.add(session.id);
|
||||
} else {
|
||||
fileMap.set(file, new Set([session.id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File nodes (shared files are larger)
|
||||
for (const [file, agentIds] of fileMap) {
|
||||
const shortName = file.split("/").pop() ?? file;
|
||||
nodes.push({
|
||||
id: `file:${file}`,
|
||||
type: "file",
|
||||
label: shortName,
|
||||
color: getFileColor(file),
|
||||
radius: 4 + Math.min(agentIds.size * 2, 6),
|
||||
});
|
||||
|
||||
// Link file to all agents that touched it
|
||||
for (const agentId of agentIds) {
|
||||
links.push({
|
||||
source: agentId,
|
||||
target: `file:${file}`,
|
||||
type: "file",
|
||||
strength: 0.3,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}, [sessions, gitActivity]);
|
||||
|
||||
// Mark nodes/links as pulsing based on recent events
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
for (const event of events.slice(0, 10)) {
|
||||
const eventTime =
|
||||
typeof event.timestamp === "string"
|
||||
? new Date(event.timestamp).getTime()
|
||||
: event.timestamp.getTime();
|
||||
if (now - eventTime < 5000) {
|
||||
// Within last 5 seconds
|
||||
const node = nodesRef.current.find((n) => n.id === event.sessionId);
|
||||
if (node) {
|
||||
node.pulse = true;
|
||||
node.lastPulse = now;
|
||||
}
|
||||
|
||||
// Pulse links to files
|
||||
if (event.data.files) {
|
||||
for (const file of event.data.files) {
|
||||
const link = linksRef.current.find(
|
||||
(l) =>
|
||||
(typeof l.source === "object" ? l.source.id : l.source) === event.sessionId &&
|
||||
(typeof l.target === "object" ? l.target.id : l.target) === `file:${file}`,
|
||||
);
|
||||
if (link) {
|
||||
link.pulse = true;
|
||||
link.lastPulse = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const updateDimensions = () => {
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
setDimensions({
|
||||
width: parent.clientWidth,
|
||||
height: Math.max(parent.clientHeight - 30, 300),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||
if (canvas.parentElement) {
|
||||
resizeObserver.observe(canvas.parentElement);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Initialize and update simulation
|
||||
useEffect(() => {
|
||||
const { nodes, links } = graphData;
|
||||
|
||||
// Copy nodes and links (D3 mutates them)
|
||||
nodesRef.current = nodes.map((n) => ({ ...n }));
|
||||
linksRef.current = links.map((l) => ({ ...l }));
|
||||
|
||||
// Create or update simulation
|
||||
if (!simulationRef.current) {
|
||||
simulationRef.current = forceSimulation<Node, Link>();
|
||||
}
|
||||
|
||||
const sim = simulationRef.current;
|
||||
sim.nodes(nodesRef.current);
|
||||
sim
|
||||
.force(
|
||||
"link",
|
||||
forceLink<Node, Link>(linksRef.current)
|
||||
.id((d) => d.id)
|
||||
.distance((d) => (d.type === "branch" ? 60 : 40))
|
||||
.strength((d) => d.strength),
|
||||
)
|
||||
.force("charge", forceManyBody().strength(-80))
|
||||
.force("center", forceCenter(dimensions.width / 2, dimensions.height / 2))
|
||||
.force(
|
||||
"collide",
|
||||
forceCollide<Node>().radius((d) => d.radius + 3),
|
||||
)
|
||||
.alpha(0.5)
|
||||
.restart();
|
||||
|
||||
return () => {
|
||||
sim.stop();
|
||||
};
|
||||
}, [graphData, dimensions]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = dimensions.width * dpr;
|
||||
canvas.height = dimensions.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const render = () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = "#0d1117";
|
||||
ctx.fillRect(0, 0, dimensions.width, dimensions.height);
|
||||
|
||||
// Draw links
|
||||
for (const link of linksRef.current) {
|
||||
const source = link.source as Node;
|
||||
const target = link.target as Node;
|
||||
if (!source.x || !source.y || !target.x || !target.y) continue;
|
||||
|
||||
// Calculate pulse effect
|
||||
const pulseAge = link.lastPulse ? now - link.lastPulse : Infinity;
|
||||
const isPulsing = pulseAge < 2000;
|
||||
const pulseAlpha = isPulsing ? Math.max(0, 1 - pulseAge / 2000) : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(source.x, source.y);
|
||||
ctx.lineTo(target.x, target.y);
|
||||
|
||||
if (isPulsing) {
|
||||
ctx.strokeStyle = `rgba(88, 166, 255, ${0.3 + pulseAlpha * 0.7})`;
|
||||
ctx.lineWidth = 1 + pulseAlpha * 2;
|
||||
} else {
|
||||
ctx.strokeStyle =
|
||||
link.type === "branch" ? "rgba(72, 79, 88, 0.6)" : "rgba(72, 79, 88, 0.3)";
|
||||
ctx.lineWidth = link.type === "branch" ? 2 : 1;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Clear pulse after fade
|
||||
if (pulseAge > 2000) {
|
||||
link.pulse = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (const node of nodesRef.current) {
|
||||
if (!node.x || !node.y) continue;
|
||||
|
||||
// Calculate pulse effect
|
||||
const pulseAge = node.lastPulse ? now - node.lastPulse : Infinity;
|
||||
const isPulsing = node.pulse || pulseAge < 3000;
|
||||
const pulsePhase = (now % 1500) / 1500;
|
||||
const pulseScale = isPulsing ? 1 + Math.sin(pulsePhase * Math.PI * 2) * 0.15 : 1;
|
||||
|
||||
// Draw glow for active nodes
|
||||
if (isPulsing && node.type === "agent") {
|
||||
const gradient = ctx.createRadialGradient(
|
||||
node.x,
|
||||
node.y,
|
||||
node.radius * pulseScale,
|
||||
node.x,
|
||||
node.y,
|
||||
node.radius * pulseScale * 2.5,
|
||||
);
|
||||
gradient.addColorStop(0, `${node.color}40`);
|
||||
gradient.addColorStop(1, "transparent");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius * pulseScale * 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Draw node
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius * pulseScale, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.fill();
|
||||
|
||||
// Draw border for main node
|
||||
if (node.type === "main") {
|
||||
ctx.strokeStyle = "#30363d";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw label for agents
|
||||
if (node.type === "agent" || node.type === "main") {
|
||||
ctx.font = node.type === "main" ? "bold 11px system-ui" : "10px system-ui";
|
||||
ctx.fillStyle = "#e6edf3";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
const label = node.type === "main" ? "main" : (node.label.split("-").pop() ?? node.label);
|
||||
ctx.fillText(label, node.x, node.y + node.radius + 12);
|
||||
}
|
||||
|
||||
// Clear pulse after some time (but keep for active agents)
|
||||
if (pulseAge > 3000 && !node.pulse) {
|
||||
node.lastPulse = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
};
|
||||
}, [dimensions]);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-[var(--color-text-muted)]">
|
||||
<svg className="mx-auto h-12 w-12 opacity-50" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth={1.5} />
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth={1.5} />
|
||||
<path stroke="currentColor" strokeWidth={1.5} d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">No agents active</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] opacity-60">
|
||||
Agents will appear as orbiting nodes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
}}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-center gap-4 text-[10px] text-[var(--color-text-muted)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-[#484f58]" />
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-[#58a6ff]" />
|
||||
agent
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-[#6e7681]" />
|
||||
file
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { SessionGitActivity } from "@/lib/git-activity";
|
||||
|
||||
interface SessionSnapshot {
|
||||
id: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
activity: string;
|
||||
pr: {
|
||||
number: number;
|
||||
state: string;
|
||||
ciStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface BranchTimelineProps {
|
||||
sessions: SessionSnapshot[];
|
||||
gitActivity: Record<string, SessionGitActivity>;
|
||||
}
|
||||
|
||||
/** Get a color for a session based on its ID (consistent hashing) */
|
||||
function getSessionColor(sessionId: string): string {
|
||||
const colors = [
|
||||
"#58a6ff", // blue
|
||||
"#3fb950", // green
|
||||
"#a371f7", // purple
|
||||
"#f0883e", // orange
|
||||
"#f778ba", // pink
|
||||
"#79c0ff", // light blue
|
||||
"#7ee787", // light green
|
||||
"#d2a8ff", // light purple
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < sessionId.length; i++) {
|
||||
hash = (hash << 5) - hash + sessionId.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/** Get status indicator color */
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "working":
|
||||
case "spawning":
|
||||
return "var(--color-accent-green)";
|
||||
case "pr_open":
|
||||
case "review_pending":
|
||||
return "var(--color-accent-blue)";
|
||||
case "ci_failed":
|
||||
case "changes_requested":
|
||||
return "var(--color-accent-red)";
|
||||
case "approved":
|
||||
case "mergeable":
|
||||
return "var(--color-accent-purple)";
|
||||
case "merged":
|
||||
case "done":
|
||||
return "var(--color-text-muted)";
|
||||
default:
|
||||
return "var(--color-accent-yellow)";
|
||||
}
|
||||
}
|
||||
|
||||
/** Get CI status indicator */
|
||||
function getCIIndicator(ciStatus: string | undefined): { color: string; label: string } {
|
||||
switch (ciStatus) {
|
||||
case "passing":
|
||||
return { color: "var(--color-accent-green)", label: "CI passing" };
|
||||
case "failing":
|
||||
return { color: "var(--color-accent-red)", label: "CI failing" };
|
||||
case "pending":
|
||||
return { color: "var(--color-accent-yellow)", label: "CI running" };
|
||||
default:
|
||||
return { color: "var(--color-text-muted)", label: "No CI" };
|
||||
}
|
||||
}
|
||||
|
||||
export function BranchTimeline({ sessions, gitActivity }: BranchTimelineProps) {
|
||||
const sortedSessions = useMemo(() => {
|
||||
return [...sessions].sort((a, b) => {
|
||||
// Active sessions first
|
||||
const aActive = a.activity === "active";
|
||||
const bActive = b.activity === "active";
|
||||
if (aActive !== bActive) return bActive ? 1 : -1;
|
||||
|
||||
// Then by commits ahead
|
||||
const aCommits = gitActivity[a.id]?.commitsAhead ?? 0;
|
||||
const bCommits = gitActivity[b.id]?.commitsAhead ?? 0;
|
||||
return bCommits - aCommits;
|
||||
});
|
||||
}, [sessions, gitActivity]);
|
||||
|
||||
if (sortedSessions.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-[var(--color-text-muted)]">
|
||||
<svg className="mx-auto h-12 w-12 opacity-50" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 7h18M3 12h18M3 17h18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">No active branches</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)] opacity-60">
|
||||
Spawn agents to see branch activity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Main branch reference line */}
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="w-[100px] flex-shrink-0 text-right">
|
||||
<span className="text-xs font-medium text-[var(--color-text-secondary)]">main</span>
|
||||
</div>
|
||||
<div className="relative h-[2px] flex-1 bg-[var(--color-border-emphasis)]">
|
||||
<div className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 border-[var(--color-border-emphasis)] bg-[var(--color-bg-primary)]" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<svg
|
||||
className="h-3 w-3 text-[var(--color-border-emphasis)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M10 3l7 7-7 7V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch swimlanes */}
|
||||
{sortedSessions.map((session) => (
|
||||
<BranchLane key={session.id} session={session} activity={gitActivity[session.id]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchLane({
|
||||
session,
|
||||
activity,
|
||||
}: {
|
||||
session: SessionSnapshot;
|
||||
activity: SessionGitActivity | undefined;
|
||||
}) {
|
||||
const color = getSessionColor(session.id);
|
||||
const statusColor = getStatusColor(session.status);
|
||||
const commits = activity?.commits ?? [];
|
||||
const commitsAhead = activity?.commitsAhead ?? 0;
|
||||
const ci = session.pr ? getCIIndicator(session.pr.ciStatus) : null;
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-md py-2 transition-colors hover:bg-[var(--color-bg-secondary)]">
|
||||
{/* Session label */}
|
||||
<div className="w-[100px] flex-shrink-0 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={session.status}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color }}>
|
||||
{session.id}
|
||||
</span>
|
||||
</div>
|
||||
{session.branch && (
|
||||
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{session.branch}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="relative flex h-8 flex-1 items-center">
|
||||
{/* Branch line */}
|
||||
<div
|
||||
className="absolute left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: `${color}40` }}
|
||||
/>
|
||||
|
||||
{/* Diverge point from main */}
|
||||
<div
|
||||
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2"
|
||||
style={{ borderColor: color, backgroundColor: "var(--color-bg-primary)" }}
|
||||
/>
|
||||
|
||||
{/* Commits as nodes */}
|
||||
<div className="absolute left-4 right-4 flex items-center justify-between">
|
||||
{commits.length > 0 ? (
|
||||
<>
|
||||
{commits.slice(0, 8).map((commit, idx) => (
|
||||
<div
|
||||
key={commit.hash}
|
||||
className="group/commit relative"
|
||||
style={{
|
||||
left: `${(idx / Math.max(commits.length - 1, 1)) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full transition-transform group-hover/commit:scale-150"
|
||||
style={{ backgroundColor: color }}
|
||||
title={commit.message}
|
||||
/>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[var(--color-bg-tertiary)] px-2 py-1 text-xs group-hover/commit:block">
|
||||
<div className="font-mono text-[var(--color-text-muted)]">
|
||||
{commit.shortHash}
|
||||
</div>
|
||||
<div className="max-w-[200px] truncate text-[var(--color-text-secondary)]">
|
||||
{commit.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] italic text-[var(--color-text-muted)]">
|
||||
No recent commits
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PR indicator if exists */}
|
||||
{session.pr && (
|
||||
<div
|
||||
className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-1 rounded-full border px-2 py-0.5"
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}20`,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] font-medium" style={{ color }}>
|
||||
PR #{session.pr.number}
|
||||
</span>
|
||||
{ci && (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: ci.color }}
|
||||
title={ci.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commits ahead badge */}
|
||||
<div className="w-[60px] flex-shrink-0 text-right">
|
||||
{commitsAhead > 0 && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: `${color}20`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
+{commitsAhead}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { ActivityFeed } from "./ActivityFeed.js";
|
||||
export { AgentMap } from "./AgentMap.js";
|
||||
export { BranchTimeline } from "./BranchTimeline.js";
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* Git Activity Polling — Tracks commits, file changes, and branch activity.
|
||||
*
|
||||
* Polls git log on each session's worktree to detect:
|
||||
* - New commits
|
||||
* - Files modified in recent commits
|
||||
* - Branch divergence from main
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { DashboardSession } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const EXEC_TIMEOUT = 10_000;
|
||||
|
||||
/** A single commit from git log */
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
timestamp: Date;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
/** Git activity for a single session */
|
||||
export interface SessionGitActivity {
|
||||
sessionId: string;
|
||||
branch: string;
|
||||
commits: GitCommit[];
|
||||
/** How many commits ahead of main branch */
|
||||
commitsAhead: number;
|
||||
/** Files changed in recent commits */
|
||||
recentFiles: string[];
|
||||
lastPolledAt: Date;
|
||||
}
|
||||
|
||||
/** A real-time activity event for the event stream */
|
||||
export interface ActivityEvent {
|
||||
id: string;
|
||||
type: "commit" | "push" | "file_change" | "pr_opened" | "ci_status" | "review";
|
||||
sessionId: string;
|
||||
branch: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
data: {
|
||||
files?: string[];
|
||||
commitHash?: string;
|
||||
prNumber?: number;
|
||||
ciStatus?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Cached git activity by session ID */
|
||||
const activityCache = new Map<string, SessionGitActivity>();
|
||||
|
||||
/** Track seen commit hashes to detect new commits */
|
||||
const seenCommits = new Map<string, Set<string>>();
|
||||
|
||||
/** Event listeners for new activity */
|
||||
type ActivityListener = (event: ActivityEvent) => void;
|
||||
const listeners: ActivityListener[] = [];
|
||||
|
||||
/** Subscribe to activity events */
|
||||
export function onActivity(listener: ActivityListener): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Emit an activity event to all listeners */
|
||||
function emitActivity(event: ActivityEvent): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Ignore listener errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a unique event ID */
|
||||
function generateEventId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git log output into commits.
|
||||
* Format: hash|short|author|timestamp|subject
|
||||
*/
|
||||
function parseGitLog(output: string, files: Map<string, string[]>): GitCommit[] {
|
||||
const commits: GitCommit[] = [];
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const [hash, shortHash, author, timestamp, ...messageParts] = line.split("|");
|
||||
if (!hash) continue;
|
||||
|
||||
commits.push({
|
||||
hash,
|
||||
shortHash: shortHash ?? hash.slice(0, 7),
|
||||
author: author ?? "unknown",
|
||||
timestamp: new Date(timestamp ?? Date.now()),
|
||||
message: messageParts.join("|"),
|
||||
files: files.get(hash) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent git activity for a session's worktree.
|
||||
* @param workspacePath - Path to the git worktree
|
||||
* @param branch - Branch name to check
|
||||
* @param defaultBranch - Main branch to compare against (e.g., "main")
|
||||
* @param sinceMinutes - How far back to look for commits
|
||||
*/
|
||||
export async function getGitActivity(
|
||||
workspacePath: string,
|
||||
branch: string,
|
||||
defaultBranch: string = "main",
|
||||
sinceMinutes: number = 30,
|
||||
): Promise<{ commits: GitCommit[]; commitsAhead: number }> {
|
||||
try {
|
||||
// Get recent commits with custom format
|
||||
const logFormat = "%H|%h|%an|%aI|%s";
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
"git",
|
||||
["log", `--since=${sinceMinutes} minutes ago`, `--format=${logFormat}`, "-n", "50"],
|
||||
{ cwd: workspacePath, timeout: EXEC_TIMEOUT },
|
||||
);
|
||||
|
||||
// Get files changed per commit
|
||||
const files = new Map<string, string[]>();
|
||||
if (logOutput.trim()) {
|
||||
const hashes = logOutput
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => l.split("|")[0])
|
||||
.filter(Boolean);
|
||||
|
||||
for (const hash of hashes.slice(0, 10)) {
|
||||
// Only get files for most recent 10 commits
|
||||
try {
|
||||
const { stdout: filesOutput } = await execFileAsync(
|
||||
"git",
|
||||
["diff-tree", "--no-commit-id", "--name-only", "-r", hash],
|
||||
{ cwd: workspacePath, timeout: EXEC_TIMEOUT },
|
||||
);
|
||||
files.set(hash, filesOutput.trim().split("\n").filter(Boolean));
|
||||
} catch {
|
||||
// Skip if we can't get files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commits = parseGitLog(logOutput, files);
|
||||
|
||||
// Count commits ahead of default branch
|
||||
let commitsAhead = 0;
|
||||
try {
|
||||
const { stdout: aheadOutput } = await execFileAsync(
|
||||
"git",
|
||||
["rev-list", "--count", `origin/${defaultBranch}..HEAD`],
|
||||
{ cwd: workspacePath, timeout: EXEC_TIMEOUT },
|
||||
);
|
||||
commitsAhead = parseInt(aheadOutput.trim(), 10) || 0;
|
||||
} catch {
|
||||
// Branch comparison might fail if remote doesn't exist
|
||||
}
|
||||
|
||||
return { commits, commitsAhead };
|
||||
} catch {
|
||||
// Git command failed — return empty
|
||||
return { commits: [], commitsAhead: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll git activity for multiple sessions.
|
||||
* Emits events for new commits detected.
|
||||
*/
|
||||
export async function pollSessionsActivity(
|
||||
sessions: DashboardSession[],
|
||||
defaultBranch: string = "main",
|
||||
): Promise<Map<string, SessionGitActivity>> {
|
||||
const results = new Map<string, SessionGitActivity>();
|
||||
|
||||
await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
if (!session.metadata?.["worktree"] || !session.branch) return;
|
||||
|
||||
const workspacePath = session.metadata["worktree"];
|
||||
const { commits, commitsAhead } = await getGitActivity(
|
||||
workspacePath,
|
||||
session.branch,
|
||||
defaultBranch,
|
||||
);
|
||||
|
||||
// Check for new commits and emit events
|
||||
const sessionSeen = seenCommits.get(session.id) ?? new Set();
|
||||
const recentFiles = new Set<string>();
|
||||
|
||||
for (const commit of commits) {
|
||||
// Collect recent files
|
||||
for (const file of commit.files) {
|
||||
recentFiles.add(file);
|
||||
}
|
||||
|
||||
// Emit event for new commits
|
||||
if (!sessionSeen.has(commit.hash)) {
|
||||
sessionSeen.add(commit.hash);
|
||||
emitActivity({
|
||||
id: generateEventId(),
|
||||
type: "commit",
|
||||
sessionId: session.id,
|
||||
branch: session.branch,
|
||||
message: `committed: "${commit.message}"`,
|
||||
timestamp: commit.timestamp,
|
||||
data: {
|
||||
files: commit.files,
|
||||
commitHash: commit.shortHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Also emit file change events for significant changes
|
||||
if (commit.files.length > 0) {
|
||||
emitActivity({
|
||||
id: generateEventId(),
|
||||
type: "file_change",
|
||||
sessionId: session.id,
|
||||
branch: session.branch,
|
||||
message: `modified ${commit.files.slice(0, 3).join(", ")}${commit.files.length > 3 ? ` +${commit.files.length - 3} more` : ""}`,
|
||||
timestamp: commit.timestamp,
|
||||
data: { files: commit.files },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seenCommits.set(session.id, sessionSeen);
|
||||
|
||||
const activity: SessionGitActivity = {
|
||||
sessionId: session.id,
|
||||
branch: session.branch,
|
||||
commits,
|
||||
commitsAhead,
|
||||
recentFiles: Array.from(recentFiles),
|
||||
lastPolledAt: new Date(),
|
||||
};
|
||||
|
||||
results.set(session.id, activity);
|
||||
activityCache.set(session.id, activity);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached activity for a session.
|
||||
*/
|
||||
export function getCachedActivity(sessionId: string): SessionGitActivity | undefined {
|
||||
return activityCache.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached activity.
|
||||
*/
|
||||
export function getAllCachedActivity(): Map<string, SessionGitActivity> {
|
||||
return new Map(activityCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear activity cache for a session.
|
||||
*/
|
||||
export function clearActivityCache(sessionId: string): void {
|
||||
activityCache.delete(sessionId);
|
||||
seenCommits.delete(sessionId);
|
||||
}
|
||||
|
|
@ -466,6 +466,9 @@ importers:
|
|||
'@xterm/addon-web-links':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
next:
|
||||
specifier: ^15.1.0
|
||||
version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -494,6 +497,9 @@ importers:
|
|||
'@testing-library/react':
|
||||
specifier: ^16.1.0
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@types/d3-force':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.14
|
||||
|
|
@ -1718,6 +1724,9 @@ packages:
|
|||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-force@3.0.10':
|
||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
|
|
@ -2166,6 +2175,22 @@ packages:
|
|||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-force@3.0.0:
|
||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-quadtree@3.0.1:
|
||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4922,6 +4947,8 @@ snapshots:
|
|||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-force@3.0.10': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
|
@ -5432,6 +5459,18 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-force@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-quadtree: 3.0.1
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-quadtree@3.0.1: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue