feat(kanban): card detail modal and rich agent-run console
- RunEventList: grouped activity timeline. Assistant text becomes chat bubbles (auto-collapsing long messages); tool_start/tool_end pair into entries with spinners and expandable input/result blocks; bevy output rolls into a live console; relative timestamps on a left rail - AgentRunBar: redesigned as a mission console. Live stats header (elapsed time, tool count, events), animated status banner with sweep/glow while running, clearer action bar. All controls preserved (run/steer/stop, diff/merge/bevy) so the human-only merge/complete safety model holds - tailwind.css: vn-flow, vn-sweep, vn-dots, vn-spin keyframes - CardModal: full card overlay (orchestrator, references, tags, comments) - DiffModal: branch-diff review (commits, stat, capped patch) - useOrchestrator: background polling + bevy status sync + ref-counted SSE - KanbanCard: pulsing agent/bevy running badge on collapsed cards
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
orchestratorApi,
|
||||
type AgentRun,
|
||||
type DiffResult,
|
||||
type MergeResult,
|
||||
type RunEvent,
|
||||
} from '../../lib/orchestratorApi';
|
||||
|
||||
@@ -39,11 +41,24 @@ export interface UseOrchestrator {
|
||||
unwatch: (runId: string) => void;
|
||||
/** Remove a settled run from the UI (and reclaim its worktree). */
|
||||
remove: (runId: string) => Promise<void>;
|
||||
/** Fetch a run's branch diff vs main. */
|
||||
getDiff: (runId: string) => Promise<DiffResult>;
|
||||
/** Merge a run's branch into the main worktree. */
|
||||
mergeRun: (runId: string) => Promise<MergeResult>;
|
||||
/** Start a Bevy playtest in a run's worktree. */
|
||||
startBevy: (runId: string) => Promise<void>;
|
||||
/** Stop a run's Bevy playtest. */
|
||||
stopBevy: (runId: string) => Promise<void>;
|
||||
/** Whether a Bevy playtest is running for a run. */
|
||||
bevyIsRunning: (runId: string) => boolean;
|
||||
/** Re-fetch a run's Bevy status from the server (truth after a reconnect). */
|
||||
refreshBevyStatus: (runId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useOrchestrator(): UseOrchestrator {
|
||||
const [runs, setRuns] = useState<AgentRun[]>([]);
|
||||
const [eventsByRun, setEventsByRun] = useState<Record<string, RunEvent[]>>({});
|
||||
const [bevyRunning, setBevyRunning] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -52,12 +67,23 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
const sources = useRef(new Map<string, EventSource>());
|
||||
const refcounts = useRef(new Map<string, number>());
|
||||
const cursors = useRef(new Map<string, number>());
|
||||
// Signature of the last loaded run list, so the background poll can skip
|
||||
// state updates (and re-renders) when nothing actually changed.
|
||||
const lastSig = useRef('');
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { runs: list } = await orchestratorApi.listRuns();
|
||||
setRuns(list);
|
||||
// Only the fields that affect what the UI renders; identities/order are
|
||||
// stable from the server (newest-first), so this is a reliable change check.
|
||||
const sig = list
|
||||
.map((r) => `${r.id}|${r.status}|${r.finishedAt ?? ''}|${r.summary ?? ''}|${r.commitSha ?? ''}`)
|
||||
.join('\n');
|
||||
if (sig !== lastSig.current) {
|
||||
lastSig.current = sig;
|
||||
setRuns(list);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load runs');
|
||||
} finally {
|
||||
@@ -69,6 +95,18 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// Background poll keeps run status fresh even when no SSE stream is open
|
||||
// (i.e. when no card modal is open). Poll faster while any run is active so a
|
||||
// collapsed card's running indicator turns over promptly when it settles.
|
||||
const anyRunning = useMemo(() => runs.some((r) => r.status === 'running'), [runs]);
|
||||
useEffect(() => {
|
||||
const ms = anyRunning ? 3_000 : 10_000;
|
||||
const id = setInterval(() => {
|
||||
void reload();
|
||||
}, ms);
|
||||
return () => clearInterval(id);
|
||||
}, [reload, anyRunning]);
|
||||
|
||||
const upsertRun = useCallback((run: AgentRun) => {
|
||||
setRuns((prev) => {
|
||||
const next = prev.filter((r) => r.id !== run.id);
|
||||
@@ -152,6 +190,16 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
prev.map((r) => (r.id === runId ? { ...r, summary: ev.data.summary as string } : r)),
|
||||
);
|
||||
}
|
||||
// Track Bevy playtest lifecycle from its events.
|
||||
if (ev.type === 'bevy') {
|
||||
const phase = ev.data.phase;
|
||||
setBevyRunning((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (phase === 'start') next.add(runId);
|
||||
else if (phase === 'end') next.delete(runId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// EventSource auto-reconnects; nothing to do here.
|
||||
@@ -208,6 +256,47 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
|
||||
const eventsForRun = useCallback((runId: string) => eventsByRun[runId] ?? [], [eventsByRun]);
|
||||
|
||||
const getDiff = useCallback(
|
||||
(runId: string) => orchestratorApi.getDiff(runId),
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeRun = useCallback(
|
||||
(runId: string) => orchestratorApi.mergeRun(runId),
|
||||
[],
|
||||
);
|
||||
|
||||
const startBevy = useCallback(async (runId: string) => {
|
||||
await orchestratorApi.startBevy(runId);
|
||||
setBevyRunning((prev) => new Set(prev).add(runId));
|
||||
}, []);
|
||||
|
||||
const stopBevy = useCallback(async (runId: string) => {
|
||||
await orchestratorApi.stopBevy(runId);
|
||||
// Optimistically clear; the `end` event reconciles.
|
||||
setBevyRunning((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(runId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const bevyIsRunning = useCallback((runId: string) => bevyRunning.has(runId), [bevyRunning]);
|
||||
|
||||
const refreshBevyStatus = useCallback(async (runId: string) => {
|
||||
try {
|
||||
const { running } = await orchestratorApi.bevyStatus(runId);
|
||||
setBevyRunning((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (running) next.add(runId);
|
||||
else next.delete(runId);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
/* server unavailable — keep current state */
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runForCard,
|
||||
isRunning,
|
||||
@@ -222,5 +311,11 @@ export function useOrchestrator(): UseOrchestrator {
|
||||
watch,
|
||||
unwatch,
|
||||
remove,
|
||||
getDiff,
|
||||
mergeRun,
|
||||
startBevy,
|
||||
stopBevy,
|
||||
bevyIsRunning,
|
||||
refreshBevyStatus,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user