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,16 +1,21 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Card } from '../../lib/kanbanApi';
|
import type { Card } from '../../lib/kanbanApi';
|
||||||
import type { RunEvent } from '../../lib/orchestratorApi';
|
|
||||||
import type { UseOrchestrator } from './useOrchestrator';
|
import type { UseOrchestrator } from './useOrchestrator';
|
||||||
|
import { DiffModal } from './DiffModal';
|
||||||
|
import { RunEventList } from './RunEventList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline agentic control for one kanban card.
|
* Inline agentic console for one kanban card.
|
||||||
*
|
*
|
||||||
* Lets the operator launch a `pi` run against the card, watch its live
|
* Lets the operator launch a `pi` run against the card, watch its live
|
||||||
* tool/thought stream, steer it mid-flight, and stop it — all while the rest of
|
* tool/thought stream, steer it mid-flight, and stop it — all while the rest of
|
||||||
* the board stays interactive. Settled runs show their outcome, commit, and
|
* the board stays interactive. Settled runs show their outcome, commit, and
|
||||||
* summary, with the worktree-backed changes recorded back onto the card and the
|
* summary, with the worktree-backed changes recorded back onto the card and the
|
||||||
* documentation by the agent itself.
|
* documentation by the agent itself.
|
||||||
|
*
|
||||||
|
* The run is presented as a small "mission console": a live stats header, a
|
||||||
|
* prominent status banner, an action bar, and a rich grouped activity log
|
||||||
|
* (assistant chat bubbles + a tool/bevy timeline).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface AgentRunBarProps {
|
interface AgentRunBarProps {
|
||||||
@@ -18,12 +23,15 @@ interface AgentRunBarProps {
|
|||||||
orch: UseOrchestrator;
|
orch: UseOrchestrator;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
const STATUS_META: Record<
|
||||||
queued: { label: 'Queued', color: 'var(--muted)' },
|
string,
|
||||||
running: { label: 'Running', color: 'var(--accent)' },
|
{ label: string; color: string; glyph: string; sub: string }
|
||||||
completed: { label: 'Completed', color: 'var(--green)' },
|
> = {
|
||||||
failed: { label: 'Failed', color: 'var(--red)' },
|
queued: { label: 'Queued', color: 'var(--muted)', glyph: '○', sub: 'Waiting to start…' },
|
||||||
stopped: { label: 'Stopped', color: 'var(--fg-dim)' },
|
running: { label: 'Running', color: 'var(--accent)', glyph: '●', sub: 'Agent is working' },
|
||||||
|
completed: { label: 'Completed', color: 'var(--green)', glyph: '✓', sub: 'Run finished successfully' },
|
||||||
|
failed: { label: 'Failed', color: 'var(--red)', glyph: '⚠', sub: 'Run ended with an error' },
|
||||||
|
stopped: { label: 'Stopped', color: 'var(--fg-dim)', glyph: '■', sub: 'Run was stopped' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
||||||
@@ -33,22 +41,45 @@ export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
|||||||
const [promptDraft, setPromptDraft] = useState('');
|
const [promptDraft, setPromptDraft] = useState('');
|
||||||
const [steerDraft, setSteerDraft] = useState('');
|
const [steerDraft, setSteerDraft] = useState('');
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
const [mergeResult, setMergeResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [mergeBusy, setMergeBusy] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
const now = useNow(isActive);
|
||||||
|
|
||||||
// Stream the active run's events while it is running.
|
const bevyRunning = run ? orch.bevyIsRunning(run.id) : false;
|
||||||
|
|
||||||
|
// Stream the run's events while it exists (agent run OR a later Bevy test);
|
||||||
|
// the card stays expanded so playtest output keeps flowing after settle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!run || !isActive) return;
|
if (!run) return;
|
||||||
orch.watch(run.id);
|
orch.watch(run.id);
|
||||||
return () => orch.unwatch(run.id);
|
return () => orch.unwatch(run.id);
|
||||||
}, [run, isActive, orch]);
|
}, [run, orch]);
|
||||||
|
|
||||||
|
// Sync Bevy status from the server when the run changes (truth after a
|
||||||
|
// reconnect/refresh; live lifecycle is mirrored via `bevy` events).
|
||||||
|
useEffect(() => {
|
||||||
|
if (run) void orch.refreshBevyStatus(run.id);
|
||||||
|
}, [run, orch]);
|
||||||
|
|
||||||
// Auto-scroll the log to the latest event.
|
|
||||||
const events = run ? orch.eventsForRun(run.id) : [];
|
const events = run ? orch.eventsForRun(run.id) : [];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
}, [events.length]);
|
}, [events.length]);
|
||||||
|
|
||||||
|
// --- derived stats ---
|
||||||
|
const toolCount = useMemo(() => events.filter((e) => e.type === 'tool_start').length, [events]);
|
||||||
|
const durationLabel = useMemo(() => {
|
||||||
|
if (!run?.startedAt) return null;
|
||||||
|
const start = Date.parse(run.startedAt);
|
||||||
|
if (!Number.isFinite(start)) return null;
|
||||||
|
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
||||||
|
return fmtDuration((Number.isFinite(end) ? end : now) - start);
|
||||||
|
}, [run, now]);
|
||||||
|
|
||||||
|
// --- handlers (unchanged behavior, clearer presentation) ---
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -94,54 +125,86 @@ export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMerge = async () => {
|
||||||
|
if (!run) return;
|
||||||
|
setMergeBusy(true);
|
||||||
|
setMergeResult(null);
|
||||||
|
try {
|
||||||
|
const r = await orch.mergeRun(run.id);
|
||||||
|
if (r.alreadyMerged) setMergeResult({ ok: true, text: `Already in ${r.target}.` });
|
||||||
|
else if (r.ok) setMergeResult({ ok: true, text: `Merged into ${r.target} — review & mark the card done.` });
|
||||||
|
else setMergeResult({ ok: false, text: r.output });
|
||||||
|
} catch (e) {
|
||||||
|
setMergeResult({ ok: false, text: e instanceof Error ? e.message : 'merge failed' });
|
||||||
|
} finally {
|
||||||
|
setMergeBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settled = run && !isActive;
|
||||||
|
const hasWorktree = Boolean(run?.branch && run.worktreePath);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={wrapStyle}>
|
<div style={panelStyle(isActive)}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
{/* Animated gradient edge while the agent is working. */}
|
||||||
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--fg)' }}>🤖 Agent</span>
|
{isActive && <span style={topGlowStyle} aria-hidden />}
|
||||||
{run && (
|
|
||||||
<StatusPill status={run.status} />
|
<Header
|
||||||
|
run={run}
|
||||||
|
toolCount={toolCount}
|
||||||
|
eventCount={events.length}
|
||||||
|
durationLabel={durationLabel}
|
||||||
|
bevyRunning={bevyRunning}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{run && <StatusBanner status={run.status} error={run.error} bevyRunning={bevyRunning} />}
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div style={actionBarStyle}>
|
||||||
|
{!run && !showPrompt && (
|
||||||
|
<button type="button" style={primaryBtn} onClick={() => setShowPrompt(true)}>
|
||||||
|
▶ Run agent
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<button type="button" style={dangerBtn} onClick={stop} disabled={busy}>
|
||||||
|
■ Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{settled && (
|
||||||
|
<>
|
||||||
|
<button type="button" style={ghostBtn} onClick={() => setShowPrompt((s) => !s)}>
|
||||||
|
↻ Re-run
|
||||||
|
</button>
|
||||||
|
<button type="button" style={ghostBtn} onClick={dismiss} title="Remove this run">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{run?.branch && (
|
{run?.branch && (
|
||||||
<span style={branchStyle} title={run.worktreePath ?? undefined}>
|
<span style={chipStyle} title={run.worktreePath ?? undefined}>
|
||||||
⎇ {run.branch}
|
⎇ {run.branch}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ marginLeft: 'auto' }}>
|
{run?.commitSha && (
|
||||||
{!run && !showPrompt && (
|
<span style={{ ...chipStyle, fontFamily: 'var(--font-mono)' }} title="Agent commit">
|
||||||
<button type="button" style={primaryBtn} onClick={() => setShowPrompt(true)}>
|
◉ {run.commitSha.slice(0, 7)}
|
||||||
▶ Run agent
|
</span>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<button type="button" style={dangerBtn} onClick={stop} disabled={busy}>
|
|
||||||
■ Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{run && !isActive && (
|
|
||||||
<>
|
|
||||||
<button type="button" style={ghostBtn} onClick={() => setShowPrompt((s) => !s)}>
|
|
||||||
↻ Re-run
|
|
||||||
</button>
|
|
||||||
<button type="button" style={ghostBtn} onClick={dismiss} title="Remove this run">
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start prompt (create or re-run) */}
|
{/* Start prompt (create or re-run) */}
|
||||||
{showPrompt && (
|
{showPrompt && (
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={blockStyle}>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Optional extra instructions for this run (e.g. 'focus on the Rust client', 'also update the economy doc')…"
|
placeholder="Optional extra instructions for this run (e.g. 'focus on the Rust client', 'also update the economy doc')…"
|
||||||
value={promptDraft}
|
value={promptDraft}
|
||||||
onChange={(e) => setPromptDraft(e.target.value)}
|
onChange={(e) => setPromptDraft(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ ...inputStyle, width: '100%', minHeight: '56px', resize: 'vertical' }}
|
style={{ ...inputStyle, width: '100%', minHeight: '60px', resize: 'vertical' }}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: '4px', display: 'flex', gap: '6px' }}>
|
<div style={{ marginTop: '6px', display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
<button type="button" style={primaryBtn} onClick={start} disabled={busy}>
|
<button type="button" style={primaryBtn} onClick={start} disabled={busy}>
|
||||||
{busy ? 'Starting…' : '▶ Start'}
|
{busy ? 'Starting…' : '▶ Start'}
|
||||||
</button>
|
</button>
|
||||||
@@ -158,10 +221,10 @@ export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
|||||||
|
|
||||||
{/* Steer box while running */}
|
{/* Steer box while running */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
<div style={{ ...blockStyle, display: 'flex', gap: '6px' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Steer the agent mid-run…"
|
placeholder="Steer the agent mid-run… (Enter to send)"
|
||||||
value={steerDraft}
|
value={steerDraft}
|
||||||
onChange={(e) => setSteerDraft(e.target.value)}
|
onChange={(e) => setSteerDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -171,163 +234,457 @@ export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<button type="button" style={smallBtn} onClick={steer} disabled={busy || !steerDraft.trim()}>
|
<button type="button" style={accentBtn} onClick={steer} disabled={busy || !steerDraft.trim()}>
|
||||||
Steer
|
➤ Steer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settled summary */}
|
{/* Settled summary */}
|
||||||
{run && !isActive && (run.summary || run.commitSha) && (
|
{settled && (run.summary || run.commitSha) && (
|
||||||
<div style={summaryStyle}>
|
<div style={summaryStyle(run.status)}>
|
||||||
{run.summary && <div style={{ whiteSpace: 'pre-wrap' }}>{run.summary}</div>}
|
{run.summary && <div style={{ whiteSpace: 'pre-wrap' }}>{run.summary}</div>}
|
||||||
<div style={{ marginTop: '4px', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
<div style={{ marginTop: '4px', fontSize: '0.64rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||||
{run.commitSha && <>commit {run.commitSha}</>}
|
{run.commitSha && <>commit {run.commitSha}</>}
|
||||||
{run.finishedAt && <> · finished {new Date(run.finishedAt).toLocaleTimeString()}</>}
|
{run.finishedAt && <> · finished {new Date(run.finishedAt).toLocaleTimeString()}</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live event log */}
|
{/* Worktree actions: review the diff, merge into main, or playtest with Bevy */}
|
||||||
{run && events.length > 0 && (
|
{hasWorktree && (
|
||||||
<div ref={logRef} style={logStyle}>
|
<div style={{ ...blockStyle, display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||||
{events.slice(-200).map((ev, i) => (
|
<span style={sectionLabelStyle}>Worktree</span>
|
||||||
<EventRow key={`${run.id}-${ev.seq ?? i}`} ev={ev} />
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
style={ghostBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowDiff(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
△ Diff
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={ghostBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void onMerge();
|
||||||
|
}}
|
||||||
|
disabled={mergeBusy}
|
||||||
|
>
|
||||||
|
{mergeBusy ? 'Merging…' : '⬇ Merge'}
|
||||||
|
</button>
|
||||||
|
{bevyRunning ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={dangerBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (run) void orch.stopBevy(run.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
■ Stop Bevy
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={accentBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (run) void orch.startBevy(run.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶ Bevy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{mergeResult && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.66rem',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: mergeResult.ok ? 'var(--green)' : 'var(--red)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
flex: '1 1 100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mergeResult.ok ? '✓ ' : '⚠ '}
|
||||||
|
{mergeResult.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live activity log */}
|
||||||
|
{run && (
|
||||||
|
<div style={logLabelStyle}>
|
||||||
|
<span>Activity</span>
|
||||||
|
{events.length > 0 && <span style={{ color: 'var(--muted)' }}> · {events.length} event{events.length === 1 ? '' : 's'}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{run && (
|
||||||
|
<div ref={logRef} style={logStyle}>
|
||||||
|
<RunEventList
|
||||||
|
events={events}
|
||||||
|
startedAtIso={run.startedAt}
|
||||||
|
active={isActive}
|
||||||
|
bevyRunning={bevyRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDiff && run && (
|
||||||
|
<DiffModal onClose={() => setShowDiff(false)} load={() => orch.getDiff(run.id)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPill({ status }: { status: string }) {
|
// --- header & banner -------------------------------------------------------
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
run,
|
||||||
|
toolCount,
|
||||||
|
eventCount,
|
||||||
|
durationLabel,
|
||||||
|
bevyRunning,
|
||||||
|
}: {
|
||||||
|
run: ReturnType<UseOrchestrator['runForCard']>;
|
||||||
|
toolCount: number;
|
||||||
|
eventCount: number;
|
||||||
|
durationLabel: string | null;
|
||||||
|
bevyRunning: boolean;
|
||||||
|
}) {
|
||||||
|
const status = run?.status ?? 'queued';
|
||||||
const meta = STATUS_META[status] ?? STATUS_META.queued;
|
const meta = STATUS_META[status] ?? STATUS_META.queued;
|
||||||
const pulse = status === 'running';
|
const live = status === 'running';
|
||||||
return (
|
return (
|
||||||
<span
|
<div style={headerStyle}>
|
||||||
style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||||
display: 'inline-flex',
|
<span style={titleStyle}>
|
||||||
alignItems: 'center',
|
<span style={titleBarStyle(meta.color)} />
|
||||||
gap: '4px',
|
AGENT
|
||||||
fontSize: '0.65rem',
|
</span>
|
||||||
padding: '2px 7px',
|
{run && (
|
||||||
borderRadius: 'var(--radius-pill)',
|
<span style={statusPillStyle(meta.color, live)}>
|
||||||
border: `1px solid ${meta.color}`,
|
<span style={pillDotStyle(meta.color, live)} />
|
||||||
background: `${meta.color}1a`,
|
{meta.label}
|
||||||
color: meta.color,
|
</span>
|
||||||
textTransform: 'capitalize',
|
)}
|
||||||
}}
|
{live && (
|
||||||
>
|
<span style={workingStyle}>
|
||||||
<span
|
<Dots color={meta.color} />
|
||||||
style={{
|
{bevyRunning ? 'Bevy live' : 'working'}
|
||||||
width: '6px',
|
</span>
|
||||||
height: '6px',
|
)}
|
||||||
borderRadius: '50%',
|
</div>
|
||||||
background: meta.color,
|
<div style={statsStyle}>
|
||||||
boxShadow: pulse ? `0 0 6px ${meta.color}` : 'none',
|
{durationLabel && <Stat icon="◷" label={durationLabel} title="Elapsed" />}
|
||||||
}}
|
{run && toolCount > 0 && <Stat icon="⚒" label={String(toolCount)} title="Tool calls" />}
|
||||||
/>
|
{run && eventCount > 0 && <Stat icon="≣" label={String(eventCount)} title="Events" />}
|
||||||
{meta.label}
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ icon, label, title }: { icon: string; label: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<span style={statStyle} title={title}>
|
||||||
|
<span style={{ opacity: 0.7 }}>{icon}</span> {label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({ ev }: { ev: RunEvent }) {
|
function StatusBanner({ status, error, bevyRunning }: { status: string; error: string | null; bevyRunning: boolean }) {
|
||||||
switch (ev.type) {
|
const meta = STATUS_META[status] ?? STATUS_META.queued;
|
||||||
case 'tool_start': {
|
const live = status === 'running';
|
||||||
const tool = String(ev.data.tool ?? 'tool');
|
return (
|
||||||
const preview = String(ev.data.preview ?? '');
|
<div style={bannerStyle(meta.color, live)}>
|
||||||
return (
|
{live && <span style={sweepStyle(meta.color)} aria-hidden />}
|
||||||
<div style={evStyle('var(--cyan)')}>
|
<span style={bannerGlyphStyle(meta.color, live)}>{meta.glyph}</span>
|
||||||
<span style={tagStyle}>▸ {tool}</span>{' '}
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<span style={previewStyle}>{preview}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<strong style={{ color: meta.color, fontSize: '0.78rem' }}>{meta.label}</strong>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--fg-dim)' }}>{meta.sub}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
{bevyRunning && (
|
||||||
}
|
<div style={{ fontSize: '0.68rem', color: 'var(--purple)', fontFamily: 'var(--font-mono)' }}>
|
||||||
case 'tool_end': {
|
● Bevy playtest running
|
||||||
const tool = String(ev.data.tool ?? 'tool');
|
</div>
|
||||||
const ok = ev.data.ok !== false;
|
)}
|
||||||
const preview = String(ev.data.preview ?? '');
|
{error && (
|
||||||
return (
|
<div style={{ marginTop: '3px', fontSize: '0.68rem', color: status === 'failed' ? 'var(--red)' : 'var(--accent)', fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
<div style={evStyle(ok ? 'var(--fg-dim)' : 'var(--red)')}>
|
{status === 'failed' ? '⚠ ' : 'ⓘ '}{error}
|
||||||
<span style={tagStyle}>{ok ? '✓' : '✗'} {tool}</span>{' '}
|
</div>
|
||||||
<span style={previewStyle}>{preview}</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
case 'text':
|
}
|
||||||
return (
|
|
||||||
<div style={{ ...evStyle('var(--fg)'), background: 'rgba(255,255,255,0.03)', borderRadius: '4px', padding: '4px 6px' }}>
|
function Dots({ color }: { color: string }) {
|
||||||
<span style={{ whiteSpace: 'pre-wrap' }}>{String(ev.data.text ?? '')}</span>
|
return (
|
||||||
</div>
|
<span style={{ display: 'inline-flex', gap: '3px', alignItems: 'center' }}>
|
||||||
);
|
{[0, 1, 2].map((i) => (
|
||||||
case 'done':
|
<span
|
||||||
return (
|
key={i}
|
||||||
<div style={evStyle('var(--green)')}>
|
style={{
|
||||||
<span style={tagStyle}>✓ done</span>{' '}
|
width: '4px',
|
||||||
<span style={previewStyle}>{String(ev.data.summary ?? '')}</span>
|
height: '4px',
|
||||||
</div>
|
borderRadius: '50%',
|
||||||
);
|
background: color,
|
||||||
case 'error':
|
animation: 'vn-dots 1.1s ease-in-out infinite',
|
||||||
return <div style={evStyle('var(--red)')}>⚠ {String(ev.data.message ?? '')}</div>;
|
animationDelay: `${i * 0.16}s`,
|
||||||
case 'log': {
|
}}
|
||||||
const level = String(ev.data.level ?? 'info');
|
/>
|
||||||
const color = level === 'error' ? 'var(--red)' : level === 'warn' ? 'var(--accent)' : 'var(--muted)';
|
))}
|
||||||
return <div style={evStyle(color)}>{String(ev.data.text ?? '')}</div>;
|
</span>
|
||||||
}
|
);
|
||||||
default:
|
}
|
||||||
return null;
|
|
||||||
}
|
/** Ticks once a second while active so the elapsed-time stat stays live. */
|
||||||
|
function useNow(active: boolean): number {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(ms: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return `${h}h ${String(m % 60).padStart(2, '0')}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- styles ----------------------------------------------------------------
|
// --- styles ----------------------------------------------------------------
|
||||||
|
|
||||||
const wrapStyle: React.CSSProperties = {
|
function panelStyle(active: boolean): React.CSSProperties {
|
||||||
marginTop: 'var(--sp-3)',
|
return {
|
||||||
padding: '10px',
|
position: 'relative',
|
||||||
border: '1px solid var(--border)',
|
marginTop: 'var(--sp-3)',
|
||||||
borderRadius: 'var(--radius-md)',
|
padding: '12px',
|
||||||
background: 'var(--surface-raised)',
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
boxShadow: active ? '0 0 0 1px rgba(240,160,48,0.25), 0 0 22px rgba(240,160,48,0.08)' : 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const topGlowStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, var(--accent), var(--cyan), var(--purple), var(--accent))',
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: 'vn-flow 3s linear infinite',
|
||||||
};
|
};
|
||||||
|
|
||||||
const branchStyle: React.CSSProperties = {
|
const headerStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
color: 'var(--fg-bright)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function titleBarStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
width: '3px',
|
||||||
|
height: '14px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 6px ${color}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPillStyle(color: string, pulse: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
fontSize: '0.66rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
background: `${color}1a`,
|
||||||
|
color,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillDotStyle(color: string, pulse: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
boxShadow: pulse ? `0 0 6px ${color}` : 'none',
|
||||||
|
animation: pulse ? 'vn-pulse 1.1s ease-in-out infinite' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.64rem',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
textTransform: 'lowercase',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statsStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.64rem',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
padding: '2px 7px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function bannerStyle(color: string, live: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '7px 10px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${color}${live ? '' : '33'}`,
|
||||||
|
background: live
|
||||||
|
? `linear-gradient(90deg, ${color}1f, ${color}07)`
|
||||||
|
: `${color}0f`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweepStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '40%',
|
||||||
|
height: '100%',
|
||||||
|
background: `linear-gradient(90deg, transparent, ${color}33, transparent)`,
|
||||||
|
animation: 'vn-sweep 2.8s ease-in-out infinite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bannerGlyphStyle(color: string, live: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
animation: live ? 'vn-pulse 1.1s ease-in-out infinite' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionBarStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: '8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockStyle: React.CSSProperties = {
|
||||||
|
marginBottom: '8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chipStyle: React.CSSProperties = {
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
fontSize: '0.62rem',
|
fontSize: '0.62rem',
|
||||||
color: 'var(--muted)',
|
color: 'var(--fg-dim)',
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
padding: '1px 6px',
|
padding: '2px 7px',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
maxWidth: '160px',
|
maxWidth: '200px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
};
|
};
|
||||||
|
|
||||||
const logStyle: React.CSSProperties = {
|
function summaryStyle(status: string): React.CSSProperties {
|
||||||
maxHeight: '220px',
|
const color = status === 'failed' ? 'var(--red)' : status === 'completed' ? 'var(--green)' : 'var(--accent)';
|
||||||
overflowY: 'auto',
|
return {
|
||||||
|
fontSize: '0.76rem',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
background: `${color}0f`,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
borderLeft: `2px solid ${color}`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '7px 9px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLabelStyle: React.CSSProperties = {
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.6rem',
|
||||||
lineHeight: 1.45,
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const logLabelStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
margin: '2px 0 4px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const logStyle: React.CSSProperties = {
|
||||||
|
maxHeight: '340px',
|
||||||
|
overflowY: 'auto',
|
||||||
background: 'var(--bg)',
|
background: 'var(--bg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
padding: '6px 8px',
|
padding: '8px 10px',
|
||||||
};
|
|
||||||
|
|
||||||
const summaryStyle: React.CSSProperties = {
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--fg-dim)',
|
|
||||||
background: 'rgba(34,197,94,0.06)',
|
|
||||||
border: '1px solid rgba(34,197,94,0.2)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
padding: '6px 8px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '4px 8px',
|
padding: '5px 9px',
|
||||||
fontSize: '0.78rem',
|
fontSize: '0.78rem',
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
@@ -337,14 +694,14 @@ const inputStyle: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const primaryBtn: React.CSSProperties = {
|
const primaryBtn: React.CSSProperties = {
|
||||||
padding: '3px 10px',
|
padding: '4px 12px',
|
||||||
fontSize: '0.74rem',
|
fontSize: '0.74rem',
|
||||||
background: 'var(--accent)',
|
background: 'var(--accent)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--surface)',
|
color: 'var(--surface)',
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dangerBtn: React.CSSProperties = {
|
const dangerBtn: React.CSSProperties = {
|
||||||
@@ -352,8 +709,14 @@ const dangerBtn: React.CSSProperties = {
|
|||||||
background: 'var(--red)',
|
background: 'var(--red)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accentBtn: React.CSSProperties = {
|
||||||
|
...primaryBtn,
|
||||||
|
background: 'var(--cyan)',
|
||||||
|
color: 'var(--bg)',
|
||||||
|
};
|
||||||
|
|
||||||
const ghostBtn: React.CSSProperties = {
|
const ghostBtn: React.CSSProperties = {
|
||||||
padding: '3px 10px',
|
padding: '4px 12px',
|
||||||
fontSize: '0.74rem',
|
fontSize: '0.74rem',
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
@@ -362,29 +725,10 @@ const ghostBtn: React.CSSProperties = {
|
|||||||
color: 'var(--fg-dim)',
|
color: 'var(--fg-dim)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const smallBtn: React.CSSProperties = {
|
|
||||||
...ghostBtn,
|
|
||||||
padding: '3px 12px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const hintStyle: React.CSSProperties = {
|
const hintStyle: React.CSSProperties = {
|
||||||
fontSize: '0.62rem',
|
fontSize: '0.62rem',
|
||||||
color: 'var(--muted)',
|
color: 'var(--muted)',
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
alignSelf: 'center',
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
};
|
minWidth: 0,
|
||||||
|
|
||||||
function evStyle(color: string): React.CSSProperties {
|
|
||||||
return { color, marginBottom: '3px', wordBreak: 'break-word' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagStyle: React.CSSProperties = {
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: 0.95,
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewStyle: React.CSSProperties = {
|
|
||||||
color: 'var(--fg-dim)',
|
|
||||||
opacity: 0.85,
|
|
||||||
};
|
};
|
||||||
|
|||||||
383
apps/docs/src/components/kanban/CardModal.tsx
Normal file
383
apps/docs/src/components/kanban/CardModal.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Card, Column, DocPage, ReferenceType } from '../../lib/kanbanApi';
|
||||||
|
import type { CustomPage } from '../../lib/pagesApi';
|
||||||
|
import { ReferenceLinks } from './ReferenceLinks';
|
||||||
|
import { AgentRunBar } from './AgentRunBar';
|
||||||
|
import type { UseOrchestrator } from './useOrchestrator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-detail overlay for a single kanban card.
|
||||||
|
*
|
||||||
|
* Opened by clicking a card on the board. Surfaces everything: the agent
|
||||||
|
* orchestrator (run/steer/stop/diff/merge/bevy), column moves, reference
|
||||||
|
* management, tags, and comments. Opening the modal also opens the run's live
|
||||||
|
* SSE stream (via the embedded AgentRunBar) so activity streams in real time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<Column, string> = {
|
||||||
|
done: 'Done',
|
||||||
|
'in-progress': 'In Progress',
|
||||||
|
todo: 'Todo',
|
||||||
|
backlog: 'Backlog',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CardModalProps {
|
||||||
|
card: Card;
|
||||||
|
pages: DocPage[];
|
||||||
|
customPages: CustomPage[];
|
||||||
|
orch: UseOrchestrator;
|
||||||
|
onClose: () => void;
|
||||||
|
onMove: (status: Column) => void;
|
||||||
|
onAddComment: (text: string) => void;
|
||||||
|
onDeleteComment: (commentId: string) => void;
|
||||||
|
onAddTag: (tag: string) => void;
|
||||||
|
onRemoveTag: (tag: string) => void;
|
||||||
|
onAddReference: (ref: { label: string; type: ReferenceType; href: string }) => void;
|
||||||
|
onRemoveReference: (href: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardModal(props: CardModalProps) {
|
||||||
|
const { card, pages, customPages, orch, onClose } = props;
|
||||||
|
const [commentDraft, setCommentDraft] = useState('');
|
||||||
|
const [tagDraft, setTagDraft] = useState('');
|
||||||
|
const [refDraft, setRefDraft] = useState('');
|
||||||
|
const [customRefDraft, setCustomRefDraft] = useState('');
|
||||||
|
|
||||||
|
const availablePages = pages.filter((p) => !card.references.some((r) => r.href === p.path));
|
||||||
|
const availableCustomPages = customPages.filter(
|
||||||
|
(p) => !card.references.some((r) => r.href === `/docs/custom/${p.slug}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlayStyle} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl border border-border bg-surface p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-5 flex items-start gap-3">
|
||||||
|
<span style={badgeStyle}>{card.id}</span>
|
||||||
|
<span style={categoryStyle}>{card.category}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<label className="font-mono text-[0.7rem] uppercase tracking-wider text-muted">Move to</label>
|
||||||
|
<select
|
||||||
|
value={card.status}
|
||||||
|
onChange={(e) => props.onMove(e.target.value as Column)}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{(Object.keys(COLUMN_LABELS) as Column[]).map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{COLUMN_LABELS[c]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer rounded border border-border bg-transparent px-2 py-0.5 text-muted hover:text-fg"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="m-0 mb-2 text-xl text-fg-bright">{card.title}</h2>
|
||||||
|
<p className="m-0 mb-1 text-[0.9rem] leading-relaxed text-fg-dim">{card.description}</p>
|
||||||
|
<p className="m-0 mb-4 text-[0.82rem] italic leading-relaxed text-fg-dim">{card.details}</p>
|
||||||
|
|
||||||
|
{(card.files || card.notes) && (
|
||||||
|
<div className="mb-4 flex flex-col gap-1">
|
||||||
|
{card.files && (
|
||||||
|
<div className="font-mono text-[0.7rem] text-muted">📁 {card.files}</div>
|
||||||
|
)}
|
||||||
|
{card.notes && <div style={noteStyle}>💡 {card.notes}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent orchestrator */}
|
||||||
|
<Section title="Agent">
|
||||||
|
<AgentRunBar card={card} orch={orch} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* References */}
|
||||||
|
<Section title={`References (${card.references.length})`}>
|
||||||
|
{availablePages.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-[0.75rem] text-fg-dim">Add doc reference:</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select value={refDraft} onChange={(e) => setRefDraft(e.target.value)} style={inputStyle}>
|
||||||
|
<option value="">Select a doc page…</option>
|
||||||
|
{availablePages.map((p) => (
|
||||||
|
<option key={p.path} value={p.path}>
|
||||||
|
{p.icon} {p.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{refDraft && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={smallButtonStyle}
|
||||||
|
onClick={() => {
|
||||||
|
const page = pages.find((p) => p.path === refDraft);
|
||||||
|
if (page) {
|
||||||
|
props.onAddReference({ label: page.title, type: 'doc', href: page.path });
|
||||||
|
setRefDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{availableCustomPages.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-[0.75rem] text-purple">Add custom page reference:</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={customRefDraft}
|
||||||
|
onChange={(e) => setCustomRefDraft(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">Select a custom page…</option>
|
||||||
|
{availableCustomPages.map((p) => (
|
||||||
|
<option key={p.id} value={p.slug}>
|
||||||
|
{p.icon} {p.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{customRefDraft && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={smallButtonStyle}
|
||||||
|
onClick={() => {
|
||||||
|
const page = customPages.find((p) => p.slug === customRefDraft);
|
||||||
|
if (page) {
|
||||||
|
props.onAddReference({
|
||||||
|
label: page.title,
|
||||||
|
type: 'custom',
|
||||||
|
href: `/docs/custom/${page.slug}`,
|
||||||
|
});
|
||||||
|
setCustomRefDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ReferenceLinks
|
||||||
|
references={card.references}
|
||||||
|
pages={pages}
|
||||||
|
customPages={customPages}
|
||||||
|
onRemove={(href) => props.onRemoveReference(href)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<Section title={`Tags (${card.tags.length})`}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
value={tagDraft}
|
||||||
|
onChange={(e) => setTagDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && tagDraft.trim()) {
|
||||||
|
props.onAddTag(tagDraft.trim());
|
||||||
|
setTagDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={smallButtonStyle}
|
||||||
|
onClick={() => {
|
||||||
|
if (tagDraft.trim()) {
|
||||||
|
props.onAddTag(tagDraft.trim());
|
||||||
|
setTagDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{card.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{card.tags.map((tag) => (
|
||||||
|
<span key={tag} style={tagStyle} onClick={() => props.onRemoveTag(tag)} title="Click to remove tag">
|
||||||
|
🏷️ {tag} ×
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="m-0 text-[0.8rem] italic text-muted">No tags yet</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<Section title={`Comments (${card.comments.length})`}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
value={commentDraft}
|
||||||
|
onChange={(e) => setCommentDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && commentDraft.trim()) {
|
||||||
|
props.onAddComment(commentDraft.trim());
|
||||||
|
setCommentDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={primaryButtonStyle}
|
||||||
|
onClick={() => {
|
||||||
|
if (commentDraft.trim()) {
|
||||||
|
props.onAddComment(commentDraft.trim());
|
||||||
|
setCommentDraft('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{card.comments.length > 0 ? (
|
||||||
|
<div className="max-h-[260px] overflow-y-auto">
|
||||||
|
{card.comments.map((comment) => (
|
||||||
|
<div key={comment.id} style={commentStyle}>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-fg">{comment.author}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onDeleteComment(comment.id)}
|
||||||
|
title="Delete comment"
|
||||||
|
style={deleteBtnStyle}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.8rem] leading-relaxed text-fg-dim">{comment.text}</div>
|
||||||
|
<div className="mt-1 font-mono text-[0.65rem] text-muted">
|
||||||
|
{new Date(comment.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="m-0 text-[0.8rem] italic text-muted">No comments yet</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- subcomponents & styles ------------------------------------------------
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 border-t border-border pt-4">
|
||||||
|
<h3 className="m-0 mb-3 text-[0.78rem] font-semibold uppercase tracking-wider text-fg-dim">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 50,
|
||||||
|
padding: 'var(--sp-4)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeStyle: React.CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
background: 'rgba(100,100,100,0.1)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const noteStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'rgba(240,160,48,0.08)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
borderLeft: '2px solid var(--accent)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: 'rgba(100,150,255,0.1)',
|
||||||
|
border: '1px solid rgba(100,150,255,0.3)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const smallButtonStyle: React.CSSProperties = {
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButtonStyle: React.CSSProperties = {
|
||||||
|
padding: '6px 16px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--surface)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const commentStyle: React.CSSProperties = {
|
||||||
|
padding: '8px 10px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBtnStyle: React.CSSProperties = {
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
padding: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
};
|
||||||
126
apps/docs/src/components/kanban/DiffModal.tsx
Normal file
126
apps/docs/src/components/kanban/DiffModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { DiffResult } from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay showing a run's branch diff against main — the commits it would
|
||||||
|
* bring in, a `--stat` summary, and the full (size-capped) patch — so the
|
||||||
|
* operator can review an agent's work before merging it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DiffModalProps {
|
||||||
|
/** Fetch the diff; throws on a missing worktree. */
|
||||||
|
load: () => Promise<DiffResult>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffModal({ load, onClose }: DiffModalProps) {
|
||||||
|
const [diff, setDiff] = useState<DiffResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setDiff(null);
|
||||||
|
load()
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setDiff(d);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load diff');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlayStyle} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-2xl border border-border bg-surface p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="m-0 text-lg">Branch Diff</h2>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer rounded border border-border bg-transparent px-2 py-0.5 text-muted hover:text-fg"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg border border-red/25 bg-red/8 px-3 py-2 font-mono text-[0.78rem] text-red">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !diff && (
|
||||||
|
<p className="font-mono text-sm text-muted">Loading diff…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diff && (
|
||||||
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
|
{diff.branch && (
|
||||||
|
<div className="font-mono text-[0.7rem] text-muted">
|
||||||
|
⎇ <span className="text-fg-dim">{diff.branch}</span> → main
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="m-0 mb-1 text-[0.75rem] uppercase tracking-wider text-muted">
|
||||||
|
Commits ({diff.commits.length})
|
||||||
|
</h3>
|
||||||
|
{diff.commits.length === 0 ? (
|
||||||
|
<p className="m-0 font-mono text-[0.72rem] text-muted">No commits ahead — already merged or empty.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 list-none p-0 font-mono text-[0.72rem]">
|
||||||
|
{diff.commits.map((c) => (
|
||||||
|
<li key={c.sha} className="mb-0.5">
|
||||||
|
<span className="text-accent">{c.sha}</span>{' '}
|
||||||
|
<span className="text-fg-dim">{c.subject}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{diff.stat && (
|
||||||
|
<div>
|
||||||
|
<h3 className="m-0 mb-1 text-[0.75rem] uppercase tracking-wider text-muted">Changes</h3>
|
||||||
|
<pre className="m-0 overflow-x-auto rounded-lg border border-border bg-bg p-3 font-mono text-[0.68rem] leading-relaxed text-fg-dim">
|
||||||
|
{diff.stat}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<h3 className="m-0 mb-1 text-[0.75rem] uppercase tracking-wider text-muted">
|
||||||
|
Patch {diff.truncated && <span style={{ color: 'var(--accent)' }}>(truncated)</span>}
|
||||||
|
</h3>
|
||||||
|
<pre
|
||||||
|
className="m-0 max-h-[44vh] overflow-auto rounded-lg border border-border bg-bg p-3 font-mono text-[0.66rem] leading-relaxed"
|
||||||
|
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||||
|
>
|
||||||
|
{diff.patch || '(no textual changes)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 50,
|
||||||
|
padding: 'var(--sp-4)',
|
||||||
|
};
|
||||||
@@ -1,355 +1,163 @@
|
|||||||
import { useState } from 'react';
|
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
|
||||||
import type { Card, Column, DocPage, ReferenceType } from '../../lib/kanbanApi';
|
|
||||||
import type { CustomPage } from '../../lib/pagesApi';
|
import type { CustomPage } from '../../lib/pagesApi';
|
||||||
import { ReferenceLinks } from './ReferenceLinks';
|
import { ReferenceLinks } from './ReferenceLinks';
|
||||||
import { AgentRunBar } from './AgentRunBar';
|
|
||||||
import type { UseOrchestrator } from './useOrchestrator';
|
import type { UseOrchestrator } from './useOrchestrator';
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<Column, string> = {
|
const COLUMN_DOT: Record<Column, string> = {
|
||||||
done: 'Done',
|
done: 'var(--green)',
|
||||||
'in-progress': 'In Progress',
|
'in-progress': 'var(--accent)',
|
||||||
todo: 'Todo',
|
todo: 'var(--red)',
|
||||||
backlog: 'Backlog',
|
backlog: 'var(--muted)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact, click-to-open kanban card.
|
||||||
|
*
|
||||||
|
* The full detail (agent orchestration, comments, references, tags) lives in
|
||||||
|
* the CardModal opened by `onOpen`. This card is a dense preview: status, id,
|
||||||
|
* title, description, references, and a prominent live indicator while an agent
|
||||||
|
* or Bevy playtest is running on the card's worktree.
|
||||||
|
*/
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
card: Card;
|
card: Card;
|
||||||
pages: DocPage[];
|
pages: DocPage[];
|
||||||
customPages: CustomPage[];
|
customPages: CustomPage[];
|
||||||
orch: UseOrchestrator;
|
orch: UseOrchestrator;
|
||||||
expanded: boolean;
|
onOpen: () => void;
|
||||||
onToggle: () => void;
|
|
||||||
onMove: (status: Column) => void;
|
|
||||||
onAddComment: (text: string) => void;
|
|
||||||
onDeleteComment: (commentId: string) => void;
|
|
||||||
onAddTag: (tag: string) => void;
|
|
||||||
onRemoveTag: (tag: string) => void;
|
|
||||||
onAddReference: (ref: { label: string; type: ReferenceType; href: string }) => void;
|
|
||||||
onRemoveReference: (href: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanCard(props: KanbanCardProps) {
|
export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCardProps) {
|
||||||
const { card, pages, customPages, orch, expanded, onToggle } = props;
|
const agentRunning = orch.isRunning(card.id);
|
||||||
const [commentDraft, setCommentDraft] = useState('');
|
const run = orch.runForCard(card.id);
|
||||||
const [tagDraft, setTagDraft] = useState('');
|
const bevyRunning = Boolean(run && orch.bevyIsRunning(run.id));
|
||||||
const [refDraft, setRefDraft] = useState('');
|
const active = agentRunning || bevyRunning;
|
||||||
const [customRefDraft, setCustomRefDraft] = useState('');
|
|
||||||
|
|
||||||
const hasComments = card.comments.length > 0;
|
|
||||||
const hasTags = card.tags.length > 0;
|
|
||||||
|
|
||||||
// Doc pages not already referenced (for the add-reference picker).
|
|
||||||
const availablePages = pages.filter(
|
|
||||||
(p) => !card.references.some((r) => r.href === p.path),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom pages not already referenced (for the add-reference picker).
|
|
||||||
const availableCustomPages = customPages.filter(
|
|
||||||
(p) => !card.references.some((r) => r.href === `/docs/custom/${p.slug}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface)',
|
...cardStyle,
|
||||||
border: `1px solid ${expanded ? 'var(--accent)' : 'var(--border)'}`,
|
// An active card is unmistakable: accent glow + tinted background +
|
||||||
borderRadius: 'var(--radius-md)',
|
// colored left rail so a running agent is visible at a glance, even
|
||||||
padding: 'var(--sp-3)',
|
// from across the board.
|
||||||
cursor: 'pointer',
|
...(active
|
||||||
transition: 'all 0.15s ease',
|
? {
|
||||||
boxShadow: expanded ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
|
borderColor: 'var(--accent)',
|
||||||
|
boxShadow: '0 0 0 1px var(--accent), 0 0 14px rgba(240,160,48,0.25)',
|
||||||
|
background: 'rgba(240,160,48,0.04)',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
borderLeft: `3px solid ${COLUMN_DOT[card.status]}`,
|
||||||
}}
|
}}
|
||||||
onClick={onToggle}
|
onClick={onOpen}
|
||||||
className="hover:shadow-sm"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Open card details"
|
||||||
>
|
>
|
||||||
{/* Header: id + category + counts */}
|
{/* Header: id + category + counts */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', marginBottom: 'var(--sp-2)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 'var(--sp-2)' }}>
|
||||||
<span style={badgeStyle}>{card.id}</span>
|
<span style={badgeStyle}>{card.id}</span>
|
||||||
<span style={categoryStyle}>{card.category}</span>
|
<span style={categoryStyle}>{card.category}</span>
|
||||||
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>
|
{active && (
|
||||||
{orch.isRunning(card.id) && (
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
<span style={{ color: 'var(--accent)', marginRight: '6px' }}>🤖 running</span>
|
<RunningBadge agent={agentRunning} bevy={bevyRunning} />
|
||||||
)}
|
</span>
|
||||||
{hasComments && `💬 ${card.comments.length} `}
|
)}
|
||||||
{hasTags && `🏷️ ${card.tags.length} `}
|
{!active && (
|
||||||
{card.references.length > 0 && `🔗 ${card.references.length}`}
|
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>
|
||||||
</span>
|
{card.comments.length > 0 && `💬 ${card.comments.length} `}
|
||||||
|
{card.tags.length > 0 && `🏷️ ${card.tags.length} `}
|
||||||
|
{card.references.length > 0 && `🔗 ${card.references.length}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.9rem', fontWeight: 500, color: 'var(--fg)' }}>
|
<h4 style={titleStyle}>{card.title}</h4>
|
||||||
{card.title}
|
<p style={descStyle}>{card.description}</p>
|
||||||
</h4>
|
|
||||||
<p style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.4 }}>
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
<p style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.75rem', color: 'var(--fg-dim)', lineHeight: 1.4, fontStyle: 'italic' }}>
|
|
||||||
{card.details}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* References (compact) */}
|
{/* References (compact) */}
|
||||||
<div style={{ marginBottom: 'var(--sp-2)' }}>
|
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
|
||||||
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: files + notes + tags */}
|
{/* Footer: files + notes + tags (read-only chips here) */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-1)' }}>
|
{(card.files || card.notes || card.tags.length > 0) && (
|
||||||
{card.files && (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: 'var(--sp-1)' }}>
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
{card.files && (
|
||||||
📁 {card.files}
|
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||||
</div>
|
📁 {card.files}
|
||||||
)}
|
</div>
|
||||||
{card.notes && (
|
)}
|
||||||
<div style={noteStyle}>💡 {card.notes}</div>
|
{card.notes && <div style={noteStyle}>💡 {card.notes}</div>}
|
||||||
)}
|
{card.tags.length > 0 && (
|
||||||
{hasTags && (
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
{card.tags.map((tag) => (
|
||||||
{card.tags.map((tag) => (
|
<span key={tag} style={tagStyle}>
|
||||||
<span
|
🏷️ {tag}
|
||||||
key={tag}
|
</span>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onRemoveTag(tag);
|
|
||||||
}}
|
|
||||||
style={tagStyle}
|
|
||||||
title="Click to remove tag"
|
|
||||||
>
|
|
||||||
🏷️ {tag} ×
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded section */}
|
|
||||||
{expanded && (
|
|
||||||
<div style={{ marginTop: 'var(--sp-3)', paddingTop: 'var(--sp-3)', borderTop: '1px solid var(--border)' }}>
|
|
||||||
{/* Agent orchestrator */}
|
|
||||||
<AgentRunBar card={card} orch={orch} />
|
|
||||||
|
|
||||||
{/* Move */}
|
|
||||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', marginRight: '8px' }}>Move to:</label>
|
|
||||||
<select
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => props.onMove(e.target.value as Column)}
|
|
||||||
value={card.status}
|
|
||||||
style={inputStyle}
|
|
||||||
>
|
|
||||||
{(Object.keys(COLUMN_LABELS) as Column[]).map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{COLUMN_LABELS[c]}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add reference (doc page) */}
|
|
||||||
{availablePages.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', display: 'block', marginBottom: '4px' }}>
|
|
||||||
Add doc reference:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
value={refDraft}
|
|
||||||
onChange={(e) => setRefDraft(e.target.value)}
|
|
||||||
style={inputStyle}
|
|
||||||
>
|
|
||||||
<option value="">Select a doc page…</option>
|
|
||||||
{availablePages.map((p) => (
|
|
||||||
<option key={p.path} value={p.path}>
|
|
||||||
{p.icon} {p.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{refDraft && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const page = pages.find((p) => p.path === refDraft);
|
|
||||||
if (page) {
|
|
||||||
props.onAddReference({ label: page.title, type: 'doc', href: page.path });
|
|
||||||
setRefDraft('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={smallButtonStyle}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add reference (custom page) */}
|
|
||||||
{availableCustomPages.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--purple)', display: 'block', marginBottom: '4px' }}>
|
|
||||||
Add custom page reference:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
value={customRefDraft}
|
|
||||||
onChange={(e) => setCustomRefDraft(e.target.value)}
|
|
||||||
style={inputStyle}
|
|
||||||
>
|
|
||||||
<option value="">Select a custom page…</option>
|
|
||||||
{availableCustomPages.map((p) => (
|
|
||||||
<option key={p.id} value={p.slug}>
|
|
||||||
{p.icon} {p.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{customRefDraft && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const page = customPages.find((p) => p.slug === customRefDraft);
|
|
||||||
if (page) {
|
|
||||||
props.onAddReference({
|
|
||||||
label: page.title,
|
|
||||||
type: 'custom',
|
|
||||||
href: `/docs/custom/${page.slug}`,
|
|
||||||
});
|
|
||||||
setCustomRefDraft('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={smallButtonStyle}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Full reference list with removal */}
|
|
||||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', display: 'block', marginBottom: '4px' }}>
|
|
||||||
References
|
|
||||||
</label>
|
|
||||||
<ReferenceLinks
|
|
||||||
references={card.references}
|
|
||||||
pages={pages}
|
|
||||||
customPages={customPages}
|
|
||||||
onRemove={(href) => props.onRemoveReference(href)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add tag */}
|
|
||||||
<div style={{ marginBottom: 'var(--sp-3)', display: 'flex', gap: '8px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Add tag…"
|
|
||||||
value={tagDraft}
|
|
||||||
onChange={(e) => setTagDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (tagDraft.trim()) {
|
|
||||||
props.onAddTag(tagDraft.trim());
|
|
||||||
setTagDraft('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (tagDraft.trim()) {
|
|
||||||
props.onAddTag(tagDraft.trim());
|
|
||||||
setTagDraft('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={smallButtonStyle}
|
|
||||||
>
|
|
||||||
Tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
<div>
|
|
||||||
<h5 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Comments</h5>
|
|
||||||
{hasComments ? (
|
|
||||||
<div style={{ marginBottom: 'var(--sp-2)', maxHeight: '200px', overflowY: 'auto' }}>
|
|
||||||
{card.comments.map((comment) => (
|
|
||||||
<div key={comment.id} style={commentStyle}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
|
||||||
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{comment.author}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onDeleteComment(comment.id);
|
|
||||||
}}
|
|
||||||
title="Delete comment"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
padding: 0,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: 'var(--fg-dim)', lineHeight: 1.4, fontSize: '0.8rem' }}>{comment.text}</div>
|
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', marginTop: '4px' }}>
|
|
||||||
{new Date(comment.createdAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', fontStyle: 'italic' }}>No comments yet</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Add a comment…"
|
|
||||||
value={commentDraft}
|
|
||||||
onChange={(e) => setCommentDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (commentDraft.trim()) {
|
|
||||||
props.onAddComment(commentDraft.trim());
|
|
||||||
setCommentDraft('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (commentDraft.trim()) {
|
|
||||||
props.onAddComment(commentDraft.trim());
|
|
||||||
setCommentDraft('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={primaryButtonStyle}
|
|
||||||
>
|
|
||||||
Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- shared styles ---------------------------------------------------------
|
/** Live activity badge: pulses while an agent or Bevy run is in flight. */
|
||||||
|
function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) {
|
||||||
|
const color = agent ? 'var(--accent)' : 'var(--purple)';
|
||||||
|
const label = agent ? 'Agent' : 'Bevy';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
background: `${color}1a`,
|
||||||
|
color,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={pulseDotStyle(color)} />
|
||||||
|
{label} running
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseDotStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 6px ${color}`,
|
||||||
|
animation: 'vn-pulse 1.1s ease-in-out infinite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- styles ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: 'var(--sp-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
};
|
||||||
|
|
||||||
const badgeStyle: React.CSSProperties = {
|
const badgeStyle: React.CSSProperties = {
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
@@ -368,6 +176,20 @@ const categoryStyle: React.CSSProperties = {
|
|||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleStyle: React.CSSProperties = {
|
||||||
|
margin: '0 0 var(--sp-1) 0',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--fg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const descStyle: React.CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
};
|
||||||
|
|
||||||
const noteStyle: React.CSSProperties = {
|
const noteStyle: React.CSSProperties = {
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
color: 'var(--accent)',
|
color: 'var(--accent)',
|
||||||
@@ -383,41 +205,4 @@ const tagStyle: React.CSSProperties = {
|
|||||||
background: 'rgba(100,150,255,0.1)',
|
background: 'rgba(100,150,255,0.1)',
|
||||||
border: '1px solid rgba(100,150,255,0.3)',
|
border: '1px solid rgba(100,150,255,0.3)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
cursor: 'pointer',
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
color: 'var(--fg)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const smallButtonStyle: React.CSSProperties = {
|
|
||||||
padding: '4px 12px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'var(--fg)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryButtonStyle: React.CSSProperties = {
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'var(--surface)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const commentStyle: React.CSSProperties = {
|
|
||||||
padding: '8px',
|
|
||||||
marginBottom: '8px',
|
|
||||||
background: 'var(--surface-raised)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
};
|
};
|
||||||
|
|||||||
840
apps/docs/src/components/kanban/RunEventList.tsx
Normal file
840
apps/docs/src/components/kanban/RunEventList.tsx
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { RunEvent } from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich, grouped renderer for an agent run's event log.
|
||||||
|
*
|
||||||
|
* Turns the flat event stream into a readable "agent activity" view:
|
||||||
|
* - assistant `text` becomes wide chat bubbles (the agent "talking"),
|
||||||
|
* - tool calls are paired (start→end) into compact timeline entries with
|
||||||
|
* status markers — a spinner while running, ✓ / ✗ when done — and an
|
||||||
|
* expandable result block,
|
||||||
|
* - bevy `start`/`end` are milestones; bevy `output` is collapsed into a
|
||||||
|
* console block that streams live while the playtest runs,
|
||||||
|
* - logs, errors, and the `done` milestone get their own treatments,
|
||||||
|
* - every entry carries a relative `+Ns` timestamp.
|
||||||
|
*
|
||||||
|
* Pure presentation: inline styles + the project's CSS-var design tokens only.
|
||||||
|
* State is limited to which tool/result blocks the user has expanded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RunEventListProps {
|
||||||
|
events: RunEvent[];
|
||||||
|
/** ISO string of run start, used for relative timestamps. */
|
||||||
|
startedAtIso?: string | null;
|
||||||
|
/** True while the agent itself is working (drives the trailing live cursor). */
|
||||||
|
active: boolean;
|
||||||
|
/** True while a Bevy playtest is running (keeps its console live). */
|
||||||
|
bevyRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- timeline model -------------------------------------------------------
|
||||||
|
|
||||||
|
interface ToolItem {
|
||||||
|
kind: 'tool';
|
||||||
|
key: string;
|
||||||
|
tool: string;
|
||||||
|
startPreview: string;
|
||||||
|
endPreview?: string;
|
||||||
|
ok?: boolean;
|
||||||
|
open: boolean;
|
||||||
|
startEv: RunEvent;
|
||||||
|
endEv?: RunEvent;
|
||||||
|
}
|
||||||
|
interface LogItem {
|
||||||
|
kind: 'log';
|
||||||
|
key: string;
|
||||||
|
ev: RunEvent;
|
||||||
|
}
|
||||||
|
interface BevyMarkerItem {
|
||||||
|
kind: 'bevy_marker';
|
||||||
|
key: string;
|
||||||
|
phase: 'start' | 'end';
|
||||||
|
text: string;
|
||||||
|
exitCode?: number;
|
||||||
|
ev: RunEvent;
|
||||||
|
}
|
||||||
|
interface BevyOutputItem {
|
||||||
|
kind: 'bevy_output';
|
||||||
|
key: string;
|
||||||
|
lines: string[];
|
||||||
|
startEv: RunEvent;
|
||||||
|
}
|
||||||
|
interface DoneItem {
|
||||||
|
kind: 'done';
|
||||||
|
key: string;
|
||||||
|
ev: RunEvent;
|
||||||
|
}
|
||||||
|
interface ErrorItem {
|
||||||
|
kind: 'error';
|
||||||
|
key: string;
|
||||||
|
ev: RunEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityItem =
|
||||||
|
| ToolItem
|
||||||
|
| LogItem
|
||||||
|
| BevyMarkerItem
|
||||||
|
| BevyOutputItem
|
||||||
|
| DoneItem
|
||||||
|
| ErrorItem;
|
||||||
|
|
||||||
|
/** Activity items rendered by the simple dispatcher (tool/bevy_output are
|
||||||
|
* handled by their own components). Each variant exposes an `ev`. */
|
||||||
|
type SimpleActivityItem = LogItem | BevyMarkerItem | DoneItem | ErrorItem;
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: 'message'; key: string; ev: RunEvent }
|
||||||
|
| { kind: 'activity'; key: string; items: ActivityItem[] };
|
||||||
|
|
||||||
|
/** Pair tool_start/tool_end, group bevy output, and split text into chat blocks. */
|
||||||
|
function buildTimeline(events: RunEvent[]): Block[] {
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
const openTools: ToolItem[] = [];
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
|
const key = (prefix: string, ev: RunEvent) => `${prefix}-${ev.seq ?? n++}`;
|
||||||
|
|
||||||
|
// Append to the trailing activity block, creating one if the last block is a
|
||||||
|
// chat message (so consecutive tool/log/bevy work shares a single rail).
|
||||||
|
const activityItems = (): ActivityItem[] => {
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (last && last.kind === 'activity') return last.items;
|
||||||
|
const block: Block = { kind: 'activity', key: `act-${blocks.length}`, items: [] };
|
||||||
|
blocks.push(block);
|
||||||
|
return block.items;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'status':
|
||||||
|
continue; // reflected by the header/banner; skip in the timeline
|
||||||
|
case 'text':
|
||||||
|
blocks.push({ kind: 'message', key: key('msg', ev), ev });
|
||||||
|
break;
|
||||||
|
case 'tool_start': {
|
||||||
|
const item: ToolItem = {
|
||||||
|
kind: 'tool',
|
||||||
|
key: key('tool', ev),
|
||||||
|
tool: String(ev.data.tool ?? 'tool'),
|
||||||
|
startPreview: String(ev.data.preview ?? ''),
|
||||||
|
open: true,
|
||||||
|
startEv: ev,
|
||||||
|
};
|
||||||
|
activityItems().push(item);
|
||||||
|
openTools.push(item);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tool_end': {
|
||||||
|
const item = openTools.pop();
|
||||||
|
if (item) {
|
||||||
|
item.open = false;
|
||||||
|
item.ok = ev.data.ok !== false;
|
||||||
|
item.endPreview = String(ev.data.preview ?? '');
|
||||||
|
item.endEv = ev;
|
||||||
|
} else {
|
||||||
|
// Orphan end (history truncated before its start) → standalone result.
|
||||||
|
activityItems().push({
|
||||||
|
kind: 'tool',
|
||||||
|
key: key('tool', ev),
|
||||||
|
tool: String(ev.data.tool ?? 'tool'),
|
||||||
|
startPreview: '',
|
||||||
|
endPreview: String(ev.data.preview ?? ''),
|
||||||
|
ok: ev.data.ok !== false,
|
||||||
|
open: false,
|
||||||
|
startEv: ev,
|
||||||
|
endEv: ev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'log':
|
||||||
|
activityItems().push({ kind: 'log', key: key('log', ev), ev });
|
||||||
|
break;
|
||||||
|
case 'bevy': {
|
||||||
|
const phase = String(ev.data.phase ?? 'output');
|
||||||
|
const text = String(ev.data.text ?? '');
|
||||||
|
if (phase === 'start' || phase === 'end') {
|
||||||
|
activityItems().push({
|
||||||
|
kind: 'bevy_marker',
|
||||||
|
key: key('bevym', ev),
|
||||||
|
phase,
|
||||||
|
text,
|
||||||
|
exitCode: typeof ev.data.exitCode === 'number' ? ev.data.exitCode : undefined,
|
||||||
|
ev,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const items = activityItems();
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (last && last.kind === 'bevy_output') {
|
||||||
|
if (text) last.lines.push(text);
|
||||||
|
} else {
|
||||||
|
items.push({ kind: 'bevy_output', key: key('bevyo', ev), lines: text ? [text] : [], startEv: ev });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'done':
|
||||||
|
activityItems().push({ kind: 'done', key: key('done', ev), ev });
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
activityItems().push({ kind: 'error', key: key('err', ev), ev });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- component ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RunEventList({ events, startedAtIso, active, bevyRunning }: RunEventListProps) {
|
||||||
|
// Keep the rendered log bounded; grouping compresses bevy/log noise first, so
|
||||||
|
// each tool/chat item stays intact even at the slice boundary.
|
||||||
|
const blocks = useMemo(() => buildTimeline(events).slice(-80), [events]);
|
||||||
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||||
|
const [openConsoles, setOpenConsoles] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const startMs = parseMs(startedAtIso);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={emptyStyle}>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<TypingDots color="var(--accent)" />
|
||||||
|
<span style={{ color: 'var(--fg-dim)' }}>
|
||||||
|
{bevyRunning ? 'Bevy is compiling / running…' : 'Waiting for the agent to respond…'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--muted)' }}>No activity recorded yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trailing bevy_output stays "live" (open + pulsing) while Bevy runs.
|
||||||
|
let liveConsoleKey: string | null = null;
|
||||||
|
if (bevyRunning) {
|
||||||
|
const lastBlock = blocks[blocks.length - 1];
|
||||||
|
if (lastBlock && lastBlock.kind === 'activity') {
|
||||||
|
const lastItem = lastBlock.items[lastBlock.items.length - 1];
|
||||||
|
if (lastItem && lastItem.kind === 'bevy_output') liveConsoleKey = lastItem.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={listStyle}>
|
||||||
|
{blocks.map((block) =>
|
||||||
|
block.kind === 'message' ? (
|
||||||
|
<ChatBubble key={block.key} text={String(block.ev.data.text ?? '')} timeMs={parseMs(block.ev.createdAt)} startMs={startMs} />
|
||||||
|
) : (
|
||||||
|
<ActivityBlock key={block.key}>
|
||||||
|
{block.items.map((item) => {
|
||||||
|
if (item.kind === 'tool') {
|
||||||
|
const open = item.open || !!expandedTools[item.key];
|
||||||
|
return (
|
||||||
|
<ToolEntry
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
expanded={open}
|
||||||
|
startMs={startMs}
|
||||||
|
onToggle={() => setExpandedTools((p) => ({ ...p, [item.key]: !p[item.key] }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.kind === 'bevy_output') {
|
||||||
|
const live = item.key === liveConsoleKey;
|
||||||
|
const open = live || !!openConsoles[item.key];
|
||||||
|
return (
|
||||||
|
<BevyConsole
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
open={open}
|
||||||
|
live={live}
|
||||||
|
startMs={startMs}
|
||||||
|
onToggle={() => setOpenConsoles((p) => ({ ...p, [item.key]: !p[item.key] }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ActivityEntry key={item.key} item={item} startMs={startMs} />;
|
||||||
|
})}
|
||||||
|
</ActivityBlock>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{active && <WorkingRow />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared bits ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** A vertical-rail activity block holding tool/log/bevy work. */
|
||||||
|
function ActivityBlock({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={activityBlockStyle}>
|
||||||
|
<span style={railStyle} aria-hidden />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row on the rail: absolutely-positioned marker + flexible content + time. */
|
||||||
|
function Row({ marker, children, time = null }: { marker: React.ReactNode; children: React.ReactNode; time?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span style={markerSlotStyle}>{marker}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||||
|
{time && <span style={timeStyle}>{time}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubble({ text, timeMs, startMs }: { text: string; timeMs: number | null; startMs: number | null }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const long = text.length > 360 || text.split('\n').length > 8;
|
||||||
|
const shown = expanded || !long ? text : truncateText(text);
|
||||||
|
return (
|
||||||
|
<div style={bubbleWrapStyle}>
|
||||||
|
<span style={avatarStyle} title="Agent">
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
<div style={bubbleStyle}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{shown}</div>
|
||||||
|
{long && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={moreBtnStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((x) => !x);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? 'show less' : `show more (${text.length} chars)`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{timeMs && (
|
||||||
|
<div style={bubbleTimeStyle}>{relTime(timeMs, startMs) ?? absShort(timeMs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolEntry({
|
||||||
|
item,
|
||||||
|
expanded,
|
||||||
|
startMs,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: ToolItem;
|
||||||
|
expanded: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const running = item.open;
|
||||||
|
const ok = item.ok !== false;
|
||||||
|
const color = running ? 'var(--accent)' : item.ok === false ? 'var(--red)' : 'var(--green)';
|
||||||
|
const hasDetail = Boolean(item.endPreview);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row marker={<ToolMarker state={running ? 'running' : ok ? 'ok' : 'fail'} />} time={relTime(parseMs(item.endEv?.createdAt ?? item.startEv.createdAt), startMs)}>
|
||||||
|
<div
|
||||||
|
style={{ ...toolCardStyle, borderColor: running ? color : 'var(--border)', cursor: hasDetail ? 'pointer' : 'default' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (hasDetail) onToggle();
|
||||||
|
}}
|
||||||
|
role={hasDetail ? 'button' : undefined}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
||||||
|
<span style={{ ...toolTagStyle(color) }}>{toolIcon(item.tool)} {item.tool}</span>
|
||||||
|
<span style={previewStyle}>{item.startPreview || (running ? '…' : '')}</span>
|
||||||
|
{hasDetail && (
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.6rem', color: 'var(--muted)', flexShrink: 0 }}>
|
||||||
|
{expanded ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && (item.startPreview || item.endPreview) && (
|
||||||
|
<div style={resultBlockStyle}>
|
||||||
|
{item.startPreview && (
|
||||||
|
<>
|
||||||
|
<span style={resultLabelStyle}>{item.tool === 'bash' ? 'command' : 'input'}</span>
|
||||||
|
<pre style={resultPreStyle}>{item.startPreview}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.endPreview && (
|
||||||
|
<>
|
||||||
|
<span style={resultLabelStyle}>result</span>
|
||||||
|
<pre style={resultPreStyle}>{item.endPreview}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BevyConsole({
|
||||||
|
item,
|
||||||
|
open,
|
||||||
|
live,
|
||||||
|
startMs,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: BevyOutputItem;
|
||||||
|
open: boolean;
|
||||||
|
live: boolean;
|
||||||
|
startMs: number | null;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const color = live ? 'var(--purple)' : 'var(--fg-dim)';
|
||||||
|
const lineCount = item.lines.length;
|
||||||
|
return (
|
||||||
|
<Row marker={<Dot color={color} pulsing={live} />} time={relTime(parseMs(item.startEv.createdAt), startMs)}>
|
||||||
|
<div
|
||||||
|
style={{ ...consoleHeadStyle(color), cursor: 'pointer' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600 }}>🎮 Bevy output</span>
|
||||||
|
<span style={{ color: 'var(--muted)' }}> · {lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||||
|
{live && <span style={liveTagStyle}>live</span>}
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'var(--muted)' }}>{open ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<pre style={consolePreStyle}>{item.lines.join('').trimEnd() || '(no output)'}</pre>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single non-tool activity item (log, bevy milestone, done, error). */
|
||||||
|
function ActivityEntry({ item, startMs }: { item: SimpleActivityItem; startMs: number | null }) {
|
||||||
|
const time = relTime(parseMs(item.ev.createdAt), startMs);
|
||||||
|
if (item.kind === 'log') {
|
||||||
|
const level = String(item.ev.data.level ?? 'info');
|
||||||
|
const text = String(item.ev.data.text ?? '');
|
||||||
|
const color = level === 'error' ? 'var(--red)' : level === 'warn' ? 'var(--accent)' : 'var(--muted)';
|
||||||
|
return (
|
||||||
|
<Row marker={<Dot color={color} size={5} />}>
|
||||||
|
<span style={{ ...logLineStyle(color) }}>
|
||||||
|
<span style={{ opacity: 0.7 }}>{level}</span> {text}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.kind === 'bevy_marker') {
|
||||||
|
const isStart = item.phase === 'start';
|
||||||
|
const color = isStart ? 'var(--purple)' : item.exitCode === 0 ? 'var(--green)' : 'var(--fg-dim)';
|
||||||
|
return (
|
||||||
|
<Row marker={<Glyph color={color}>{isStart ? '▶' : '■'}</Glyph>} time={time}>
|
||||||
|
<span style={{ ...milestoneLineStyle(color) }}>
|
||||||
|
{item.text}
|
||||||
|
{typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||||
|
<span style={{ color: 'var(--accent)' }}> · exit {item.exitCode}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.kind === 'done') {
|
||||||
|
const summary = String(item.ev.data.summary ?? '');
|
||||||
|
return (
|
||||||
|
<Row marker={<Glyph color="var(--green)" solid>✓</Glyph>} time={time}>
|
||||||
|
<span style={doneLineStyle}>
|
||||||
|
<strong style={{ color: 'var(--green)' }}>Run complete</strong>
|
||||||
|
{summary && <span style={{ color: 'var(--fg-dim)' }}> · {summary}</span>}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// error
|
||||||
|
const message = String(item.ev.data.message ?? '');
|
||||||
|
return (
|
||||||
|
<Row marker={<Glyph color="var(--red)" solid>!</Glyph>} time={time}>
|
||||||
|
<div style={errorLineStyle}>
|
||||||
|
<strong style={{ color: 'var(--red)' }}>Error</strong>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--fg-dim)' }}>{message}</div>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkingRow() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 2px', color: 'var(--accent)' }}>
|
||||||
|
<TypingDots color="var(--accent)" />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem' }}>working…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypingDots({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', gap: '3px', alignItems: 'center' }}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
animation: 'vn-dots 1.1s ease-in-out infinite',
|
||||||
|
animationDelay: `${i * 0.16}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- markers --------------------------------------------------------------
|
||||||
|
|
||||||
|
function ToolMarker({ state }: { state: 'running' | 'ok' | 'fail' }) {
|
||||||
|
if (state === 'running') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '13px',
|
||||||
|
height: '13px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid var(--accent)',
|
||||||
|
borderTopColor: 'transparent',
|
||||||
|
animation: 'vn-spin 0.7s linear infinite',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
aria-label="running"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const color = state === 'ok' ? 'var(--green)' : 'var(--red)';
|
||||||
|
const ch = state === 'ok' ? '✓' : '✗';
|
||||||
|
return <Glyph color={color} solid>{ch}</Glyph>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color, size = 9, pulsing = false }: { color: string; size?: number; pulsing?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
boxShadow: pulsing ? `0 0 6px ${color}` : 'none',
|
||||||
|
animation: pulsing ? 'vn-pulse 1.1s ease-in-out infinite' : undefined,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Glyph({ color, solid = false, children }: { color: string; solid?: boolean; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '15px',
|
||||||
|
height: '15px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: solid ? 'var(--bg)' : color,
|
||||||
|
background: solid ? color : `${color}22`,
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseMs(iso?: string | null): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
return Number.isFinite(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative time from run start, e.g. "+12s" / "+1m05s". */
|
||||||
|
function relTime(t: number | null, startMs: number | null): string | null {
|
||||||
|
if (t == null || startMs == null) return null;
|
||||||
|
const s = Math.max(0, Math.floor((t - startMs) / 1000));
|
||||||
|
if (s < 60) return `+${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return `+${m}m${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absShort(t: number): string {
|
||||||
|
const d = new Date(t);
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a long assistant message to a few lines/characters for skimming. */
|
||||||
|
function truncateText(text: string, maxChars = 360, maxLines = 8): string {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
if (lines.length > maxLines) return `${lines.slice(0, maxLines).join('\n')}…`;
|
||||||
|
if (text.length > maxChars) return `${text.slice(0, maxChars).replace(/\s+\S*$/, '')}…`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolIcon(tool: string): string {
|
||||||
|
switch (tool) {
|
||||||
|
case 'bash':
|
||||||
|
return '⌘';
|
||||||
|
case 'read':
|
||||||
|
return '▤';
|
||||||
|
case 'edit':
|
||||||
|
case 'write':
|
||||||
|
return '✎';
|
||||||
|
case 'grep':
|
||||||
|
return '⌕';
|
||||||
|
case 'glob':
|
||||||
|
return '◇';
|
||||||
|
default:
|
||||||
|
return '⚒';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- styles ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const listStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '14px 4px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityBlockStyle: React.CSSProperties = {
|
||||||
|
position: 'relative',
|
||||||
|
paddingLeft: '22px',
|
||||||
|
margin: '6px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const railStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '7px',
|
||||||
|
top: '4px',
|
||||||
|
bottom: '4px',
|
||||||
|
width: '2px',
|
||||||
|
background: 'var(--border)',
|
||||||
|
borderRadius: '1px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowStyle: React.CSSProperties = {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '3px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerSlotStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-20px',
|
||||||
|
top: '4px',
|
||||||
|
width: '15px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeStyle: React.CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
paddingTop: '1px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewStyle: React.CSSProperties = {
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
opacity: 0.9,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolCardStyle: React.CSSProperties = {
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
padding: '3px 7px',
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolTagStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.66rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
background: `${color}1a`,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '1px 5px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultBlockStyle: React.CSSProperties = {
|
||||||
|
marginTop: '5px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultLabelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.58rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
marginBottom: '2px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultPreStyle: React.CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '5px 7px',
|
||||||
|
fontSize: '0.66rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflow: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
function consoleHeadStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '3px 7px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: `${color}14`,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveTagStyle: React.CSSProperties = {
|
||||||
|
marginLeft: '4px',
|
||||||
|
fontSize: '0.56rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--purple)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const consolePreStyle: React.CSSProperties = {
|
||||||
|
margin: '4px 0 0 0',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: '2px solid var(--purple)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 8px',
|
||||||
|
fontSize: '0.66rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflow: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
function logLineStyle(color: string): React.CSSProperties {
|
||||||
|
return { color, opacity: 0.9, wordBreak: 'break-word' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneLineStyle(color: string): React.CSSProperties {
|
||||||
|
return { color, wordBreak: 'break-word' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneLineStyle: React.CSSProperties = {
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorLineStyle: React.CSSProperties = {
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- chat bubble -----------------------------------------------------------
|
||||||
|
|
||||||
|
const bubbleWrapStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
margin: '8px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarStyle: React.CSSProperties = {
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
background: 'radial-gradient(circle at 30% 30%, rgba(34,211,238,0.35), rgba(167,139,250,0.25))',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bubbleStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
borderLeft: '2px solid var(--cyan)',
|
||||||
|
borderRadius: '2px var(--radius-md) var(--radius-md) var(--radius-md)',
|
||||||
|
padding: '7px 10px',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
fontFamily: 'var(--font-body)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
lineHeight: 1.55,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bubbleTimeStyle: React.CSSProperties = {
|
||||||
|
marginTop: '4px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.58rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const moreBtnStyle: React.CSSProperties = {
|
||||||
|
marginTop: '5px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
color: 'var(--cyan)',
|
||||||
|
};
|
||||||
@@ -24,7 +24,6 @@ interface UseKanbanBoard {
|
|||||||
ref: { label: string; type: ReferenceType; href: string },
|
ref: { label: string; type: ReferenceType; href: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeReference: (cardId: string, href: string) => Promise<void>;
|
removeReference: (cardId: string, href: string) => Promise<void>;
|
||||||
reset: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKanbanBoard(): UseKanbanBoard {
|
export function useKanbanBoard(): UseKanbanBoard {
|
||||||
@@ -208,16 +207,6 @@ export function useKanbanBoard(): UseKanbanBoard {
|
|||||||
[run],
|
[run],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(async () => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await kanbanApi.reset();
|
|
||||||
await reload();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Reset failed');
|
|
||||||
}
|
|
||||||
}, [reload]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
board,
|
board,
|
||||||
pages,
|
pages,
|
||||||
@@ -231,6 +220,5 @@ export function useKanbanBoard(): UseKanbanBoard {
|
|||||||
removeTag,
|
removeTag,
|
||||||
addReference,
|
addReference,
|
||||||
removeReference,
|
removeReference,
|
||||||
reset,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
orchestratorApi,
|
orchestratorApi,
|
||||||
type AgentRun,
|
type AgentRun,
|
||||||
|
type DiffResult,
|
||||||
|
type MergeResult,
|
||||||
type RunEvent,
|
type RunEvent,
|
||||||
} from '../../lib/orchestratorApi';
|
} from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
@@ -39,11 +41,24 @@ export interface UseOrchestrator {
|
|||||||
unwatch: (runId: string) => void;
|
unwatch: (runId: string) => void;
|
||||||
/** Remove a settled run from the UI (and reclaim its worktree). */
|
/** Remove a settled run from the UI (and reclaim its worktree). */
|
||||||
remove: (runId: string) => Promise<void>;
|
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 {
|
export function useOrchestrator(): UseOrchestrator {
|
||||||
const [runs, setRuns] = useState<AgentRun[]>([]);
|
const [runs, setRuns] = useState<AgentRun[]>([]);
|
||||||
const [eventsByRun, setEventsByRun] = useState<Record<string, RunEvent[]>>({});
|
const [eventsByRun, setEventsByRun] = useState<Record<string, RunEvent[]>>({});
|
||||||
|
const [bevyRunning, setBevyRunning] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -52,12 +67,23 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
const sources = useRef(new Map<string, EventSource>());
|
const sources = useRef(new Map<string, EventSource>());
|
||||||
const refcounts = useRef(new Map<string, number>());
|
const refcounts = useRef(new Map<string, number>());
|
||||||
const cursors = 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 () => {
|
const reload = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { runs: list } = await orchestratorApi.listRuns();
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load runs');
|
setError(e instanceof Error ? e.message : 'Failed to load runs');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,6 +95,18 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
void reload();
|
void reload();
|
||||||
}, [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) => {
|
const upsertRun = useCallback((run: AgentRun) => {
|
||||||
setRuns((prev) => {
|
setRuns((prev) => {
|
||||||
const next = prev.filter((r) => r.id !== run.id);
|
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)),
|
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 = () => {
|
es.onerror = () => {
|
||||||
// EventSource auto-reconnects; nothing to do here.
|
// EventSource auto-reconnects; nothing to do here.
|
||||||
@@ -208,6 +256,47 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
|
|
||||||
const eventsForRun = useCallback((runId: string) => eventsByRun[runId] ?? [], [eventsByRun]);
|
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 {
|
return {
|
||||||
runForCard,
|
runForCard,
|
||||||
isRunning,
|
isRunning,
|
||||||
@@ -222,5 +311,11 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
watch,
|
watch,
|
||||||
unwatch,
|
unwatch,
|
||||||
remove,
|
remove,
|
||||||
|
getDiff,
|
||||||
|
mergeRun,
|
||||||
|
startBevy,
|
||||||
|
stopBevy,
|
||||||
|
bevyIsRunning,
|
||||||
|
refreshBevyStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,4 @@ export const kanbanApi = {
|
|||||||
req<void>(`/cards/${cardId}/references?href=${encodeURIComponent(href)}`, {
|
req<void>(`/cards/${cardId}/references?href=${encodeURIComponent(href)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reset: () => req<{ ok: boolean }>('/reset', { method: 'POST' }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type RunEventType =
|
|||||||
| 'tool_start'
|
| 'tool_start'
|
||||||
| 'tool_end'
|
| 'tool_end'
|
||||||
| 'log'
|
| 'log'
|
||||||
|
| 'bevy'
|
||||||
| 'done'
|
| 'done'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
@@ -79,6 +80,30 @@ export interface StartRunInput {
|
|||||||
cleanupOnFinish?: boolean;
|
cleanupOnFinish?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A commit on a run's branch that is not yet on main. */
|
||||||
|
export interface DiffCommit {
|
||||||
|
sha: string;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run's branch diff vs main. */
|
||||||
|
export interface DiffResult {
|
||||||
|
branch: string;
|
||||||
|
commits: DiffCommit[];
|
||||||
|
stat: string;
|
||||||
|
patch: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of merging a run's branch into the main worktree. */
|
||||||
|
export interface MergeResult {
|
||||||
|
ok: boolean;
|
||||||
|
alreadyMerged: boolean;
|
||||||
|
target: string;
|
||||||
|
branch: string;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const orchestratorApi = {
|
export const orchestratorApi = {
|
||||||
listRuns: (cardId?: string) =>
|
listRuns: (cardId?: string) =>
|
||||||
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
||||||
@@ -99,4 +124,18 @@ export const orchestratorApi = {
|
|||||||
req<{ ok: boolean }>(`/runs/${id}/stop`, { method: 'POST' }),
|
req<{ ok: boolean }>(`/runs/${id}/stop`, { method: 'POST' }),
|
||||||
|
|
||||||
deleteRun: (id: string) => req<void>(`/runs/${id}`, { method: 'DELETE' }),
|
deleteRun: (id: string) => req<void>(`/runs/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// --- worktree review & playtesting ---
|
||||||
|
|
||||||
|
getDiff: (id: string) => req<DiffResult>(`/runs/${id}/diff`),
|
||||||
|
|
||||||
|
mergeRun: (id: string) =>
|
||||||
|
req<MergeResult>(`/runs/${id}/merge`, { method: 'POST' }),
|
||||||
|
|
||||||
|
bevyStatus: (id: string) => req<{ running: boolean }>(`/runs/${id}/bevy`),
|
||||||
|
|
||||||
|
startBevy: (id: string) => req<{ ok: boolean }>(`/runs/${id}/bevy`, { method: 'POST' }),
|
||||||
|
|
||||||
|
stopBevy: (id: string) =>
|
||||||
|
req<{ ok: boolean }>(`/runs/${id}/bevy/stop`, { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
||||||
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
||||||
|
import { CardModal } from '../../components/kanban/CardModal';
|
||||||
import { useOrchestrator } from '../../components/kanban/useOrchestrator';
|
import { useOrchestrator } from '../../components/kanban/useOrchestrator';
|
||||||
import { useCustomPages } from '../../lib/customPagesStore';
|
import { useCustomPages } from '../../lib/customPagesStore';
|
||||||
import type { Card, Column } from '../../lib/kanbanApi';
|
import type { Card, Column } from '../../lib/kanbanApi';
|
||||||
@@ -34,7 +35,6 @@ export function KanbanBoardPage() {
|
|||||||
removeTag,
|
removeTag,
|
||||||
addReference,
|
addReference,
|
||||||
removeReference,
|
removeReference,
|
||||||
reset,
|
|
||||||
} = useKanbanBoard();
|
} = useKanbanBoard();
|
||||||
|
|
||||||
// Custom pages power the "custom page reference" picker on each card.
|
// Custom pages power the "custom page reference" picker on each card.
|
||||||
@@ -43,7 +43,26 @@ export function KanbanBoardPage() {
|
|||||||
// Agentic orchestrator: launch/watch/steer pi runs from the board.
|
// Agentic orchestrator: launch/watch/steer pi runs from the board.
|
||||||
const orch = useOrchestrator();
|
const orch = useOrchestrator();
|
||||||
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [openCardId, setOpenCardId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
|
// The card whose detail modal is open (null when closed).
|
||||||
|
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
|
||||||
|
|
||||||
|
// Manually re-sync with the backend, confirming every optimistic change is
|
||||||
|
// persisted. The board already auto-saves on each mutation; this gives the
|
||||||
|
// operator an explicit save affordance with clear feedback.
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await reload();
|
||||||
|
setJustSaved(true);
|
||||||
|
window.setTimeout(() => setJustSaved(false), 1500);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const byColumn = useMemo(() => {
|
const byColumn = useMemo(() => {
|
||||||
const groups: Record<Column, Card[]> = { done: [], 'in-progress': [], todo: [], backlog: [] };
|
const groups: Record<Column, Card[]> = { done: [], 'in-progress': [], todo: [], backlog: [] };
|
||||||
@@ -79,24 +98,16 @@ export function KanbanBoardPage() {
|
|||||||
<h1 style={{ marginBottom: '0' }}>Implementation Board</h1>
|
<h1 style={{ marginBottom: '0' }}>Implementation Board</h1>
|
||||||
<div style={{ display: 'flex', gap: 'var(--sp-2)', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 'var(--sp-2)', alignItems: 'center' }}>
|
||||||
<SyncStatus loading={loading} error={error} />
|
<SyncStatus loading={loading} error={error} />
|
||||||
<button type="button" onClick={() => void reload()} style={headerButtonStyle}>
|
|
||||||
Reload
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => void save()}
|
||||||
if (
|
disabled={saving}
|
||||||
confirm(
|
style={primaryHeaderButtonStyle}
|
||||||
'Reset the board? This clears all comments, tags, and user references, and restores cards to their default columns.',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void reset();
|
|
||||||
setExpandedId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={headerButtonStyle}
|
|
||||||
>
|
>
|
||||||
Reset
|
{saving ? 'Saving…' : justSaved ? '✓ Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void reload()} style={headerButtonStyle}>
|
||||||
|
Reload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,27 +177,6 @@ export function KanbanBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Insight */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '720px',
|
|
||||||
marginBottom: 'var(--sp-5)',
|
|
||||||
padding: '14px 20px',
|
|
||||||
border: '1px solid rgba(245,158,11,0.3)',
|
|
||||||
background: 'rgba(245,158,11,0.1)',
|
|
||||||
color: 'var(--amber, #f59e0b)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>Current state:</strong> complete onboarding (galaxy → character → base) plus built-but-unbacked
|
|
||||||
frameworks for NPC AI, narrative event logging/history, and in-system contextual actions. Still missing the
|
|
||||||
playable loop: no damage/combat resolution, no resources/cargo, no economy, no disk persistence. See the{' '}
|
|
||||||
<Link to="/docs/roadmap" style={{ color: 'inherit', textDecoration: 'underline' }}>Roadmap</Link> and{' '}
|
|
||||||
<Link to="/docs/gap-analysis" style={{ color: 'inherit', textDecoration: 'underline' }}>Gap Analysis</Link>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -226,15 +216,7 @@ export function KanbanBoardPage() {
|
|||||||
pages={pages}
|
pages={pages}
|
||||||
customPages={customPages}
|
customPages={customPages}
|
||||||
orch={orch}
|
orch={orch}
|
||||||
expanded={expandedId === card.id}
|
onOpen={() => setOpenCardId(card.id)}
|
||||||
onToggle={() => setExpandedId(expandedId === card.id ? null : card.id)}
|
|
||||||
onMove={(status) => void moveCard(card.id, status)}
|
|
||||||
onAddComment={(text) => void addComment(card.id, text, DEFAULT_AUTHOR)}
|
|
||||||
onDeleteComment={(commentId) => void deleteComment(commentId, card.id)}
|
|
||||||
onAddTag={(tag) => void addTag(card.id, tag)}
|
|
||||||
onRemoveTag={(tag) => void removeTag(card.id, tag)}
|
|
||||||
onAddReference={(ref) => void addReference(card.id, ref)}
|
|
||||||
onRemoveReference={(href) => void removeReference(card.id, href)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,6 +237,23 @@ export function KanbanBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{openCard && (
|
||||||
|
<CardModal
|
||||||
|
card={openCard}
|
||||||
|
pages={pages}
|
||||||
|
customPages={customPages}
|
||||||
|
orch={orch}
|
||||||
|
onClose={() => setOpenCardId(null)}
|
||||||
|
onMove={(status) => void moveCard(openCard.id, status)}
|
||||||
|
onAddComment={(text) => void addComment(openCard.id, text, DEFAULT_AUTHOR)}
|
||||||
|
onDeleteComment={(commentId) => void deleteComment(commentId, openCard.id)}
|
||||||
|
onAddTag={(tag) => void addTag(openCard.id, tag)}
|
||||||
|
onRemoveTag={(tag) => void removeTag(openCard.id, tag)}
|
||||||
|
onAddReference={(ref) => void addReference(openCard.id, ref)}
|
||||||
|
onRemoveReference={(href) => void removeReference(openCard.id, href)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -312,3 +311,11 @@ const headerButtonStyle: React.CSSProperties = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--fg)',
|
color: 'var(--fg)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const primaryHeaderButtonStyle: React.CSSProperties = {
|
||||||
|
...headerButtonStyle,
|
||||||
|
background: 'var(--accent)',
|
||||||
|
border: '1px solid var(--accent)',
|
||||||
|
color: 'var(--surface)',
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,6 +29,57 @@
|
|||||||
--spacing-content: 1100px;
|
--spacing-content: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Live indicator for active agent/Bevy runs (used by inline-styled badges). */
|
||||||
|
@keyframes vn-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Moving gradient sweep across an active run's status banner. */
|
||||||
|
@keyframes vn-sweep {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-120%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(220%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouncing dots used by "working…" / typing indicators. */
|
||||||
|
@keyframes vn-dots {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner for an in-flight tool call. */
|
||||||
|
@keyframes vn-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flowing gradient along an active run's top edge (background-position). */
|
||||||
|
@keyframes vn-flow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--bg: var(--color-bg);
|
--bg: var(--color-bg);
|
||||||
|
|||||||
Reference in New Issue
Block a user