- 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
225 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|