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,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,
};
}