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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user