feat(kanban): persist board, reference pages, and run agents
Replace the localStorage kanban with the backend-backed board, add typed clients and a React hook with optimistic updates. Cards can reference static doc pages and user-created custom pages (new "custom" reference type with purple chips). Add the agentic orchestrator UI: a per-card panel to launch `pi` runs, watch a live tool/thought stream over SSE, steer mid-run, and stop — while the board stays fully interactive. The board page wires the orchestrator and custom-pages stores into every card.
This commit is contained in:
390
apps/docs/src/components/kanban/AgentRunBar.tsx
Normal file
390
apps/docs/src/components/kanban/AgentRunBar.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Card } from '../../lib/kanbanApi';
|
||||||
|
import type { RunEvent } from '../../lib/orchestratorApi';
|
||||||
|
import type { UseOrchestrator } from './useOrchestrator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline agentic control for one kanban card.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* the board stays interactive. Settled runs show their outcome, commit, and
|
||||||
|
* summary, with the worktree-backed changes recorded back onto the card and the
|
||||||
|
* documentation by the agent itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AgentRunBarProps {
|
||||||
|
card: Card;
|
||||||
|
orch: UseOrchestrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
queued: { label: 'Queued', color: 'var(--muted)' },
|
||||||
|
running: { label: 'Running', color: 'var(--accent)' },
|
||||||
|
completed: { label: 'Completed', color: 'var(--green)' },
|
||||||
|
failed: { label: 'Failed', color: 'var(--red)' },
|
||||||
|
stopped: { label: 'Stopped', color: 'var(--fg-dim)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentRunBar({ card, orch }: AgentRunBarProps) {
|
||||||
|
const run = orch.runForCard(card.id);
|
||||||
|
const isActive = run?.status === 'running';
|
||||||
|
|
||||||
|
const [promptDraft, setPromptDraft] = useState('');
|
||||||
|
const [steerDraft, setSteerDraft] = useState('');
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Stream the active run's events while it is running.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run || !isActive) return;
|
||||||
|
orch.watch(run.id);
|
||||||
|
return () => orch.unwatch(run.id);
|
||||||
|
}, [run, isActive, orch]);
|
||||||
|
|
||||||
|
// Auto-scroll the log to the latest event.
|
||||||
|
const events = run ? orch.eventsForRun(run.id) : [];
|
||||||
|
useEffect(() => {
|
||||||
|
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||||
|
}, [events.length]);
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await orch.start({ cardId: card.id, prompt: promptDraft.trim() || undefined });
|
||||||
|
setPromptDraft('');
|
||||||
|
setShowPrompt(false);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Failed to start run');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steer = async () => {
|
||||||
|
if (!run || !steerDraft.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await orch.message(run.id, steerDraft.trim(), 'steer');
|
||||||
|
setSteerDraft('');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Failed to steer');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
if (!run) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await orch.stop(run.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = async () => {
|
||||||
|
if (!run) return;
|
||||||
|
try {
|
||||||
|
await orch.remove(run.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Failed to remove run');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={wrapStyle}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--fg)' }}>🤖 Agent</span>
|
||||||
|
{run && (
|
||||||
|
<StatusPill status={run.status} />
|
||||||
|
)}
|
||||||
|
{run?.branch && (
|
||||||
|
<span style={branchStyle} title={run.worktreePath ?? undefined}>
|
||||||
|
⎇ {run.branch}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Start prompt (create or re-run) */}
|
||||||
|
{showPrompt && (
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<textarea
|
||||||
|
placeholder="Optional extra instructions for this run (e.g. 'focus on the Rust client', 'also update the economy doc')…"
|
||||||
|
value={promptDraft}
|
||||||
|
onChange={(e) => setPromptDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ ...inputStyle, width: '100%', minHeight: '56px', resize: 'vertical' }}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '4px', display: 'flex', gap: '6px' }}>
|
||||||
|
<button type="button" style={primaryBtn} onClick={start} disabled={busy}>
|
||||||
|
{busy ? 'Starting…' : '▶ Start'}
|
||||||
|
</button>
|
||||||
|
<button type="button" style={ghostBtn} onClick={() => setShowPrompt(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span style={hintStyle}>
|
||||||
|
Runs in an isolated git worktree. The agent reads the docs, implements,
|
||||||
|
verifies with cargo/pnpm, then updates the card & docs.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steer box while running */}
|
||||||
|
{isActive && (
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Steer the agent mid-run…"
|
||||||
|
value={steerDraft}
|
||||||
|
onChange={(e) => setSteerDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' && steerDraft.trim()) void steer();
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button type="button" style={smallBtn} onClick={steer} disabled={busy || !steerDraft.trim()}>
|
||||||
|
Steer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settled summary */}
|
||||||
|
{run && !isActive && (run.summary || run.commitSha) && (
|
||||||
|
<div style={summaryStyle}>
|
||||||
|
{run.summary && <div style={{ whiteSpace: 'pre-wrap' }}>{run.summary}</div>}
|
||||||
|
<div style={{ marginTop: '4px', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{run.commitSha && <>commit {run.commitSha}</>}
|
||||||
|
{run.finishedAt && <> · finished {new Date(run.finishedAt).toLocaleTimeString()}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live event log */}
|
||||||
|
{run && events.length > 0 && (
|
||||||
|
<div ref={logRef} style={logStyle}>
|
||||||
|
{events.slice(-200).map((ev, i) => (
|
||||||
|
<EventRow key={`${run.id}-${ev.seq ?? i}`} ev={ev} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const meta = STATUS_META[status] ?? STATUS_META.queued;
|
||||||
|
const pulse = status === 'running';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
border: `1px solid ${meta.color}`,
|
||||||
|
background: `${meta.color}1a`,
|
||||||
|
color: meta.color,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: '6px',
|
||||||
|
height: '6px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: meta.color,
|
||||||
|
boxShadow: pulse ? `0 0 6px ${meta.color}` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ ev }: { ev: RunEvent }) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'tool_start': {
|
||||||
|
const tool = String(ev.data.tool ?? 'tool');
|
||||||
|
const preview = String(ev.data.preview ?? '');
|
||||||
|
return (
|
||||||
|
<div style={evStyle('var(--cyan)')}>
|
||||||
|
<span style={tagStyle}>▸ {tool}</span>{' '}
|
||||||
|
<span style={previewStyle}>{preview}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'tool_end': {
|
||||||
|
const tool = String(ev.data.tool ?? 'tool');
|
||||||
|
const ok = ev.data.ok !== false;
|
||||||
|
const preview = String(ev.data.preview ?? '');
|
||||||
|
return (
|
||||||
|
<div style={evStyle(ok ? 'var(--fg-dim)' : 'var(--red)')}>
|
||||||
|
<span style={tagStyle}>{ok ? '✓' : '✗'} {tool}</span>{' '}
|
||||||
|
<span style={previewStyle}>{preview}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<div style={{ ...evStyle('var(--fg)'), background: 'rgba(255,255,255,0.03)', borderRadius: '4px', padding: '4px 6px' }}>
|
||||||
|
<span style={{ whiteSpace: 'pre-wrap' }}>{String(ev.data.text ?? '')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'done':
|
||||||
|
return (
|
||||||
|
<div style={evStyle('var(--green)')}>
|
||||||
|
<span style={tagStyle}>✓ done</span>{' '}
|
||||||
|
<span style={previewStyle}>{String(ev.data.summary ?? '')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'error':
|
||||||
|
return <div style={evStyle('var(--red)')}>⚠ {String(ev.data.message ?? '')}</div>;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- styles ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const wrapStyle: React.CSSProperties = {
|
||||||
|
marginTop: 'var(--sp-3)',
|
||||||
|
padding: '10px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const branchStyle: React.CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
maxWidth: '160px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const logStyle: React.CSSProperties = {
|
||||||
|
maxHeight: '220px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--fg)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryBtn: React.CSSProperties = {
|
||||||
|
padding: '3px 10px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--surface)',
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dangerBtn: React.CSSProperties = {
|
||||||
|
...primaryBtn,
|
||||||
|
background: 'var(--red)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostBtn: React.CSSProperties = {
|
||||||
|
padding: '3px 10px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const smallBtn: React.CSSProperties = {
|
||||||
|
...ghostBtn,
|
||||||
|
padding: '3px 12px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hintStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
alignSelf: 'center',
|
||||||
|
flex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
423
apps/docs/src/components/kanban/KanbanCard.tsx
Normal file
423
apps/docs/src/components/kanban/KanbanCard.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<Column, string> = {
|
||||||
|
done: 'Done',
|
||||||
|
'in-progress': 'In Progress',
|
||||||
|
todo: 'Todo',
|
||||||
|
backlog: 'Backlog',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KanbanCardProps {
|
||||||
|
card: Card;
|
||||||
|
pages: DocPage[];
|
||||||
|
customPages: CustomPage[];
|
||||||
|
orch: UseOrchestrator;
|
||||||
|
expanded: boolean;
|
||||||
|
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) {
|
||||||
|
const { card, pages, customPages, orch, expanded, onToggle } = props;
|
||||||
|
const [commentDraft, setCommentDraft] = useState('');
|
||||||
|
const [tagDraft, setTagDraft] = useState('');
|
||||||
|
const [refDraft, setRefDraft] = useState('');
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: `1px solid ${expanded ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: 'var(--sp-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
boxShadow: expanded ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
|
||||||
|
}}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="hover:shadow-sm"
|
||||||
|
>
|
||||||
|
{/* Header: id + category + counts */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', marginBottom: 'var(--sp-2)' }}>
|
||||||
|
<span style={badgeStyle}>{card.id}</span>
|
||||||
|
<span style={categoryStyle}>{card.category}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>
|
||||||
|
{orch.isRunning(card.id) && (
|
||||||
|
<span style={{ color: 'var(--accent)', marginRight: '6px' }}>🤖 running</span>
|
||||||
|
)}
|
||||||
|
{hasComments && `💬 ${card.comments.length} `}
|
||||||
|
{hasTags && `🏷️ ${card.tags.length} `}
|
||||||
|
{card.references.length > 0 && `🔗 ${card.references.length}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.9rem', fontWeight: 500, color: 'var(--fg)' }}>
|
||||||
|
{card.title}
|
||||||
|
</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) */}
|
||||||
|
<div style={{ marginBottom: 'var(--sp-2)' }}>
|
||||||
|
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: files + notes + tags */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-1)' }}>
|
||||||
|
{card.files && (
|
||||||
|
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
📁 {card.files}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.notes && (
|
||||||
|
<div style={noteStyle}>💡 {card.notes}</div>
|
||||||
|
)}
|
||||||
|
{hasTags && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
|
{card.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared styles ---------------------------------------------------------
|
||||||
|
|
||||||
|
const badgeStyle: React.CSSProperties = {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
background: 'var(--surface-raised)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'var(--fg-dim)',
|
||||||
|
background: 'rgba(100,100,100,0.1)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const noteStyle: React.CSSProperties = {
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
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.65rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
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.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)',
|
||||||
|
};
|
||||||
140
apps/docs/src/components/kanban/ReferenceLinks.tsx
Normal file
140
apps/docs/src/components/kanban/ReferenceLinks.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { DocPage, Reference } from '../../lib/kanbanApi';
|
||||||
|
import type { CustomPage } from '../../lib/pagesApi';
|
||||||
|
|
||||||
|
interface ReferenceLinksProps {
|
||||||
|
references: Reference[];
|
||||||
|
pages: DocPage[];
|
||||||
|
/** User-created custom pages, used to resolve icons/blurbs for `custom` refs. */
|
||||||
|
customPages?: CustomPage[];
|
||||||
|
/** Compact chips (card footer) vs full rows (expanded card). */
|
||||||
|
compact?: boolean;
|
||||||
|
onRemove?: (href: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<Reference['type'], string> = {
|
||||||
|
doc: '◈',
|
||||||
|
custom: '✦',
|
||||||
|
code: '📁',
|
||||||
|
demo: '▶',
|
||||||
|
external: '↗',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build the internal route for a custom page reference. */
|
||||||
|
const customHref = (slug: string) => `/docs/custom/${slug}`;
|
||||||
|
|
||||||
|
export function ReferenceLinks({ references, pages, customPages, compact, onRemove }: ReferenceLinksProps) {
|
||||||
|
if (references.length === 0) {
|
||||||
|
return compact ? null : (
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--muted)', fontStyle: 'italic', margin: 0 }}>
|
||||||
|
No references yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageByPath = new Map(pages.map((p) => [p.path, p]));
|
||||||
|
const customByHref = new Map((customPages ?? []).map((p) => [customHref(p.slug), p]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
|
{references.map((r) => {
|
||||||
|
const customPage = customByHref.get(r.href);
|
||||||
|
const icon =
|
||||||
|
r.type === 'doc'
|
||||||
|
? (pageByPath.get(r.href)?.icon ?? TYPE_ICON.doc)
|
||||||
|
: r.type === 'custom'
|
||||||
|
? (customPage?.icon ?? TYPE_ICON.custom)
|
||||||
|
: TYPE_ICON[r.type];
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<span style={{ opacity: 0.8 }}>{icon}</span> {r.label}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// `doc` and `custom` references are both internal routes.
|
||||||
|
const isInternal = r.type === 'doc' || r.type === 'custom';
|
||||||
|
const body = isInternal ? (
|
||||||
|
<Link
|
||||||
|
to={r.href}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={chipStyle(r.type, compact)}
|
||||||
|
title={
|
||||||
|
r.type === 'doc'
|
||||||
|
? (pageByPath.get(r.href)?.blurb ?? r.label)
|
||||||
|
: (customPage?.blurb ?? r.label)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={r.href}
|
||||||
|
target={r.type === 'external' ? '_blank' : undefined}
|
||||||
|
rel={r.type === 'external' ? 'noreferrer' : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={chipStyle(r.type, compact)}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={r.href} style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||||
|
{body}
|
||||||
|
{r.removable && onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(r.href);
|
||||||
|
}}
|
||||||
|
title="Remove reference"
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
padding: '0 2px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipStyle(type: Reference['type'], compact?: boolean): React.CSSProperties {
|
||||||
|
const color =
|
||||||
|
type === 'doc'
|
||||||
|
? 'var(--cyan)'
|
||||||
|
: type === 'custom'
|
||||||
|
? 'var(--purple)'
|
||||||
|
: type === 'code'
|
||||||
|
? 'var(--fg-dim)'
|
||||||
|
: type === 'demo'
|
||||||
|
? 'var(--accent)'
|
||||||
|
: 'var(--muted)';
|
||||||
|
const accentBorder =
|
||||||
|
type === 'doc' ? 'rgba(100,180,255,0.3)' : type === 'custom' ? 'rgba(167,139,250,0.3)' : 'var(--border)';
|
||||||
|
const accentBg =
|
||||||
|
type === 'doc' ? 'rgba(100,180,255,0.08)' : type === 'custom' ? 'rgba(167,139,250,0.08)' : 'var(--surface-raised)';
|
||||||
|
return {
|
||||||
|
fontSize: compact ? '0.65rem' : '0.72rem',
|
||||||
|
padding: '2px 7px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: `1px solid ${accentBorder}`,
|
||||||
|
background: accentBg,
|
||||||
|
color,
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
};
|
||||||
|
}
|
||||||
236
apps/docs/src/components/kanban/useKanbanBoard.ts
Normal file
236
apps/docs/src/components/kanban/useKanbanBoard.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
kanbanApi,
|
||||||
|
type Board,
|
||||||
|
type Column,
|
||||||
|
type DocPage,
|
||||||
|
type ReferenceType,
|
||||||
|
} from '../../lib/kanbanApi';
|
||||||
|
|
||||||
|
interface UseKanbanBoard {
|
||||||
|
board: Board | null;
|
||||||
|
pages: DocPage[];
|
||||||
|
loading: boolean;
|
||||||
|
/** Last mutation error, if any (cleared on next successful action/reload). */
|
||||||
|
error: string | null;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
moveCard: (id: string, status: Column) => Promise<void>;
|
||||||
|
addComment: (cardId: string, text: string, author: string) => Promise<void>;
|
||||||
|
deleteComment: (commentId: string, cardId: string) => Promise<void>;
|
||||||
|
addTag: (cardId: string, tag: string) => Promise<void>;
|
||||||
|
removeTag: (cardId: string, tag: string) => Promise<void>;
|
||||||
|
addReference: (
|
||||||
|
cardId: string,
|
||||||
|
ref: { label: string; type: ReferenceType; href: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
removeReference: (cardId: string, href: string) => Promise<void>;
|
||||||
|
reset: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKanbanBoard(): UseKanbanBoard {
|
||||||
|
const [board, setBoard] = useState<Board | null>(null);
|
||||||
|
const [pages, setPages] = useState<DocPage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [b, p] = await Promise.all([kanbanApi.getBoard(), kanbanApi.getPages()]);
|
||||||
|
setBoard(b);
|
||||||
|
setPages(p);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load board');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
/** Run a mutation, optimistic-local-updating via `apply`, then reconcile. */
|
||||||
|
const run = useCallback(
|
||||||
|
async (
|
||||||
|
apply: () => void,
|
||||||
|
remote: () => Promise<unknown>,
|
||||||
|
): Promise<void> => {
|
||||||
|
setError(null);
|
||||||
|
apply(); // optimistic local update
|
||||||
|
try {
|
||||||
|
await remote();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Mutation failed');
|
||||||
|
await reload(); // reconcile on failure
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[reload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveCard = useCallback(
|
||||||
|
(id: string, status: Column) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, status } : c)) }
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.moveCard(id, status),
|
||||||
|
),
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addComment = useCallback(
|
||||||
|
async (cardId: string, text: string, author: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const comment = await kanbanApi.addComment(cardId, text, author);
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId ? { ...c, comments: [...c.comments, comment] } : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to add comment');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteComment = useCallback(
|
||||||
|
(commentId: string, cardId: string) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId
|
||||||
|
? { ...c, comments: c.comments.filter((cm) => cm.id !== commentId) }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.deleteComment(commentId),
|
||||||
|
),
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addTag = useCallback(
|
||||||
|
(cardId: string, tag: string) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId && !c.tags.includes(tag)
|
||||||
|
? { ...c, tags: [...c.tags, tag] }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.addTag(cardId, tag),
|
||||||
|
),
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeTag = useCallback(
|
||||||
|
(cardId: string, tag: string) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId ? { ...c, tags: c.tags.filter((t) => t !== tag) } : c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.removeTag(cardId, tag),
|
||||||
|
),
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addReference = useCallback(
|
||||||
|
(cardId: string, ref: { label: string; type: ReferenceType; href: string }) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId && !c.references.some((r) => r.href === ref.href)
|
||||||
|
? { ...c, references: [...c.references, { ...ref, removable: true }] }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.addReference(cardId, ref),
|
||||||
|
),
|
||||||
|
[run],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeReference = useCallback(
|
||||||
|
(cardId: string, href: string) =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
setBoard((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
cards: prev.cards.map((c) =>
|
||||||
|
c.id === cardId
|
||||||
|
? { ...c, references: c.references.filter((r) => r.href !== href) }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
),
|
||||||
|
() => kanbanApi.removeReference(cardId, href),
|
||||||
|
),
|
||||||
|
[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 {
|
||||||
|
board,
|
||||||
|
pages,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
reload,
|
||||||
|
moveCard,
|
||||||
|
addComment,
|
||||||
|
deleteComment,
|
||||||
|
addTag,
|
||||||
|
removeTag,
|
||||||
|
addReference,
|
||||||
|
removeReference,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
226
apps/docs/src/components/kanban/useOrchestrator.ts
Normal file
226
apps/docs/src/components/kanban/useOrchestrator.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
orchestratorApi,
|
||||||
|
type AgentRun,
|
||||||
|
type RunEvent,
|
||||||
|
} from '../../lib/orchestratorApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the agentic orchestrator from the board UI.
|
||||||
|
*
|
||||||
|
* Holds the latest run per card plus a live, replayable event log for any run
|
||||||
|
* currently being "watched" (typically the expanded card's active run). Live
|
||||||
|
* updates arrive over Server-Sent Events; the board stays fully interactive
|
||||||
|
* while an agent works a card — start, steer, and stop at any time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UseOrchestrator {
|
||||||
|
/** Newest run for a card (active if one is running, else the last settled). */
|
||||||
|
runForCard: (cardId: string) => AgentRun | undefined;
|
||||||
|
/** True while a run is actively working the given card. */
|
||||||
|
isRunning: (cardId: string) => boolean;
|
||||||
|
/** All known runs (newest first), for a global activity view. */
|
||||||
|
runs: AgentRun[];
|
||||||
|
/** Watched events keyed by run id (ordered). */
|
||||||
|
eventsForRun: (runId: string) => RunEvent[];
|
||||||
|
/** Load initial state. */
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Begin a run for a card. Returns the created run. */
|
||||||
|
start: (input: { cardId: string; prompt?: string }) => Promise<AgentRun>;
|
||||||
|
/** Send a steer/follow-up message to an active run. */
|
||||||
|
message: (runId: string, text: string, mode: 'steer' | 'followUp') => Promise<void>;
|
||||||
|
/** Stop an active run. */
|
||||||
|
stop: (runId: string) => Promise<void>;
|
||||||
|
/** Open the live event stream for a run (ref-counted; safe to call repeatedly). */
|
||||||
|
watch: (runId: string) => void;
|
||||||
|
/** Release a watch (ref-counted; closes the stream when the last watcher leaves). */
|
||||||
|
unwatch: (runId: string) => void;
|
||||||
|
/** Remove a settled run from the UI (and reclaim its worktree). */
|
||||||
|
remove: (runId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrchestrator(): UseOrchestrator {
|
||||||
|
const [runs, setRuns] = useState<AgentRun[]>([]);
|
||||||
|
const [eventsByRun, setEventsByRun] = useState<Record<string, RunEvent[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refcounted EventSource subscriptions + replay cursors, kept in refs so the
|
||||||
|
// SSE callbacks always see fresh state without re-subscribing.
|
||||||
|
const sources = useRef(new Map<string, EventSource>());
|
||||||
|
const refcounts = useRef(new Map<string, number>());
|
||||||
|
const cursors = useRef(new Map<string, number>());
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { runs: list } = await orchestratorApi.listRuns();
|
||||||
|
setRuns(list);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load runs');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const upsertRun = useCallback((run: AgentRun) => {
|
||||||
|
setRuns((prev) => {
|
||||||
|
const next = prev.filter((r) => r.id !== run.id);
|
||||||
|
next.unshift(run);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendEvent = useCallback((runId: string, ev: RunEvent) => {
|
||||||
|
setEventsByRun((prev) => {
|
||||||
|
const cur = prev[runId] ?? [];
|
||||||
|
// Dedup by seq when present (history flush vs. live may overlap).
|
||||||
|
if (typeof ev.seq === 'number' && cur.some((e) => e.seq === ev.seq)) return prev;
|
||||||
|
return { ...prev, [runId]: [...cur, ev] };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (input: { cardId: string; prompt?: string }) => {
|
||||||
|
const { run } = await orchestratorApi.startRun(input);
|
||||||
|
upsertRun(run);
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
[upsertRun],
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = useCallback(
|
||||||
|
async (runId: string, text: string, mode: 'steer' | 'followUp') => {
|
||||||
|
await orchestratorApi.messageRun(runId, text, mode);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(
|
||||||
|
async (runId: string) => {
|
||||||
|
await orchestratorApi.stopRun(runId);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(async (runId: string) => {
|
||||||
|
await orchestratorApi.deleteRun(runId);
|
||||||
|
setRuns((prev) => prev.filter((r) => r.id !== runId));
|
||||||
|
setEventsByRun((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[runId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** (Re)open the SSE stream for a run, replaying persisted history first. */
|
||||||
|
const openStream = useCallback(
|
||||||
|
(runId: string) => {
|
||||||
|
if (sources.current.has(runId)) return;
|
||||||
|
const since = cursors.current.get(runId) ?? 0;
|
||||||
|
const es = new EventSource(`/api/orchestrator/runs/${runId}/stream?since=${since}`);
|
||||||
|
sources.current.set(runId, es);
|
||||||
|
es.addEventListener('event', (msg) => {
|
||||||
|
const ev = JSON.parse((msg as MessageEvent).data) as RunEvent;
|
||||||
|
if (typeof ev.seq === 'number') cursors.current.set(runId, ev.seq);
|
||||||
|
appendEvent(runId, ev);
|
||||||
|
// Reflect status changes onto the run record.
|
||||||
|
if (ev.type === 'status') {
|
||||||
|
const status = ev.data.status as AgentRun['status'];
|
||||||
|
setRuns((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === runId
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
status,
|
||||||
|
finishedAt: ['completed', 'failed', 'stopped'].includes(status)
|
||||||
|
? new Date().toISOString()
|
||||||
|
: r.finishedAt,
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ev.type === 'done' && typeof ev.data.summary === 'string') {
|
||||||
|
setRuns((prev) =>
|
||||||
|
prev.map((r) => (r.id === runId ? { ...r, summary: ev.data.summary as string } : r)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
// EventSource auto-reconnects; nothing to do here.
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[appendEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const watch = useCallback(
|
||||||
|
(runId: string) => {
|
||||||
|
const n = (refcounts.current.get(runId) ?? 0) + 1;
|
||||||
|
refcounts.current.set(runId, n);
|
||||||
|
if (n === 1) openStream(runId);
|
||||||
|
},
|
||||||
|
[openStream],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unwatch = useCallback((runId: string) => {
|
||||||
|
const n = (refcounts.current.get(runId) ?? 0) - 1;
|
||||||
|
if (n > 0) {
|
||||||
|
refcounts.current.set(runId, n);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refcounts.current.delete(runId);
|
||||||
|
const es = sources.current.get(runId);
|
||||||
|
if (es) {
|
||||||
|
es.close();
|
||||||
|
sources.current.delete(runId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close all streams on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const es of sources.current.values()) es.close();
|
||||||
|
sources.current.clear();
|
||||||
|
refcounts.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runForCard = useCallback(
|
||||||
|
(cardId: string) => {
|
||||||
|
const forCard = runs.filter((r) => r.cardId === cardId);
|
||||||
|
const active = forCard.find((r) => r.status === 'running');
|
||||||
|
return active ?? forCard[0];
|
||||||
|
},
|
||||||
|
[runs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRunning = useCallback(
|
||||||
|
(cardId: string) => Boolean(runs.find((r) => r.cardId === cardId && r.status === 'running')),
|
||||||
|
[runs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventsForRun = useCallback((runId: string) => eventsByRun[runId] ?? [], [eventsByRun]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runForCard,
|
||||||
|
isRunning,
|
||||||
|
runs,
|
||||||
|
eventsForRun,
|
||||||
|
reload,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
start,
|
||||||
|
message,
|
||||||
|
stop,
|
||||||
|
watch,
|
||||||
|
unwatch,
|
||||||
|
remove,
|
||||||
|
};
|
||||||
|
}
|
||||||
125
apps/docs/src/lib/kanbanApi.ts
Normal file
125
apps/docs/src/lib/kanbanApi.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Typed client for the @void-nav/api kanban backend.
|
||||||
|
*
|
||||||
|
* In development, requests are proxied to `http://localhost:3001` by the Vite
|
||||||
|
* config (`server.proxy['/api']`), so the docs site and API share one origin.
|
||||||
|
* Keep these types in sync with `apps/api/src/types.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Column = 'done' | 'in-progress' | 'todo' | 'backlog';
|
||||||
|
|
||||||
|
export type ReferenceType = 'doc' | 'code' | 'demo' | 'external' | 'custom';
|
||||||
|
|
||||||
|
export interface Reference {
|
||||||
|
label: string;
|
||||||
|
type: ReferenceType;
|
||||||
|
href: string;
|
||||||
|
/** Canonical references come from the seed and cannot be removed by users. */
|
||||||
|
removable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
cardId: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
/** Epoch milliseconds. */
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Card {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
details: string;
|
||||||
|
category: string;
|
||||||
|
files: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
status: Column;
|
||||||
|
sortOrder: number;
|
||||||
|
comments: Comment[];
|
||||||
|
tags: string[];
|
||||||
|
references: Reference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Board {
|
||||||
|
cards: Card[];
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocPage {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
blurb: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/kanban';
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = `${res.status} ${res.statusText}`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.error) detail = body.error;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body; keep status text */
|
||||||
|
}
|
||||||
|
throw new ApiError(detail, res.status);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kanbanApi = {
|
||||||
|
getBoard: () => req<Board>('/board'),
|
||||||
|
getPages: () => req<DocPage[]>('/pages'),
|
||||||
|
|
||||||
|
moveCard: (id: string, status: Column) =>
|
||||||
|
req<Card>(`/cards/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) }),
|
||||||
|
|
||||||
|
addComment: (cardId: string, text: string, author: string) =>
|
||||||
|
req<Comment>(`/cards/${cardId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text, author }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteComment: (commentId: string) =>
|
||||||
|
req<void>(`/comments/${commentId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
addTag: (cardId: string, tag: string) =>
|
||||||
|
req<{ cardId: string; tag: string }>(`/cards/${cardId}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tag }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeTag: (cardId: string, tag: string) =>
|
||||||
|
req<void>(`/cards/${cardId}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
addReference: (cardId: string, ref: { label: string; type: ReferenceType; href: string }) =>
|
||||||
|
req<Reference>(`/cards/${cardId}/references`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ref),
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeReference: (cardId: string, href: string) =>
|
||||||
|
req<void>(`/cards/${cardId}/references?href=${encodeURIComponent(href)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
reset: () => req<{ ok: boolean }>('/reset', { method: 'POST' }),
|
||||||
|
};
|
||||||
102
apps/docs/src/lib/orchestratorApi.ts
Normal file
102
apps/docs/src/lib/orchestratorApi.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Typed client for the @void-nav/api orchestrator backend.
|
||||||
|
*
|
||||||
|
* The orchestrator turns the implementation board into an agentic system: each
|
||||||
|
* run hands a kanban card to a `pi` subprocess (running inside an isolated git
|
||||||
|
* worktree) and streams its progress back to the UI. The agent drives the board
|
||||||
|
* and the documentation through the server's internal endpoints, so the board
|
||||||
|
* stays fully interactive and the docs remain the central store of truth.
|
||||||
|
*
|
||||||
|
* Keep these types in sync with `apps/api/src/types.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||||
|
|
||||||
|
export interface AgentRun {
|
||||||
|
id: string;
|
||||||
|
cardId: string;
|
||||||
|
status: RunStatus;
|
||||||
|
useWorktree: boolean;
|
||||||
|
branch: string | null;
|
||||||
|
worktreePath: string | null;
|
||||||
|
prompt: string;
|
||||||
|
summary: string | null;
|
||||||
|
commitSha: string | null;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunEventType =
|
||||||
|
| 'status'
|
||||||
|
| 'text'
|
||||||
|
| 'tool_start'
|
||||||
|
| 'tool_end'
|
||||||
|
| 'log'
|
||||||
|
| 'done'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface RunEvent {
|
||||||
|
seq?: number;
|
||||||
|
type: RunEventType;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/orchestrator';
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = `${res.status} ${res.statusText}`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.error) detail = body.error;
|
||||||
|
} catch {
|
||||||
|
/* keep status text */
|
||||||
|
}
|
||||||
|
throw new ApiError(detail, res.status);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRunInput {
|
||||||
|
cardId: string;
|
||||||
|
prompt?: string;
|
||||||
|
useWorktree?: boolean;
|
||||||
|
cleanupOnFinish?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orchestratorApi = {
|
||||||
|
listRuns: (cardId?: string) =>
|
||||||
|
req<{ runs: AgentRun[] }>(`/runs${cardId ? `?cardId=${encodeURIComponent(cardId)}` : ''}`),
|
||||||
|
|
||||||
|
getRun: (id: string, since = 0) =>
|
||||||
|
req<{ run: AgentRun; events: RunEvent[] }>(`/runs/${id}?since=${since}`),
|
||||||
|
|
||||||
|
startRun: (input: StartRunInput) =>
|
||||||
|
req<{ run: AgentRun }>(`/runs`, { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
|
||||||
|
messageRun: (id: string, text: string, mode: 'steer' | 'followUp') =>
|
||||||
|
req<{ ok: boolean }>(`/runs/${id}/message`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text, mode }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
stopRun: (id: string) =>
|
||||||
|
req<{ ok: boolean }>(`/runs/${id}/stop`, { method: 'POST' }),
|
||||||
|
|
||||||
|
deleteRun: (id: string) => req<void>(`/runs/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user