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:
2026-06-16 15:44:29 -04:00
parent c24a6106bf
commit a3c72bb878
8 changed files with 1906 additions and 908 deletions

View 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' }),
};