Files
Space-Game/apps/docs/src/components/kanban/useKanbanBoard.ts
francy51 72a41c2d76 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
2026-06-16 18:17:35 -04:00

225 lines
5.8 KiB
TypeScript

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>;
}
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],
);
return {
board,
pages,
loading,
error,
reload,
moveCard,
addComment,
deleteComment,
addTag,
removeTag,
addReference,
removeReference,
};
}