feat: add is_chat field to board memory and task_id to approvals, update pagination and response models

This commit is contained in:
Abhimanyu Saharan
2026-02-06 19:11:11 +05:30
parent d86fe0a7a6
commit 6c14af0451
76 changed files with 2070 additions and 571 deletions

View File

@@ -116,7 +116,7 @@ export default function EditAgentPage() {
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
@@ -148,7 +148,8 @@ export default function EditAgentPage() {
},
});
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
const boards =
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
const loadedAgent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;

View File

@@ -106,7 +106,7 @@ export default function AgentDetailPage() {
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 60_000,
@@ -118,9 +118,11 @@ export default function AgentDetailPage() {
const agent: AgentRead | null =
agentQuery.data?.status === 200 ? agentQuery.data.data : null;
const events: ActivityEventRead[] =
activityQuery.data?.status === 200 ? activityQuery.data.data : [];
activityQuery.data?.status === 200
? activityQuery.data.data.items ?? []
: [];
const boards: BoardRead[] =
boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
const agentEvents = useMemo(() => {
if (!agent) return [];

View File

@@ -91,7 +91,7 @@ export default function NewAgentPage() {
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
@@ -111,7 +111,8 @@ export default function NewAgentPage() {
},
});
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
const boards =
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [];
const displayBoardId = boardId || boards[0]?.id || "";
const isLoading = boardsQuery.isLoading || createAgentMutation.isPending;
const errorMessage = error ?? boardsQuery.error?.message ?? null;

View File

@@ -98,7 +98,7 @@ export default function AgentsPage() {
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
@@ -109,7 +109,7 @@ export default function AgentsPage() {
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 15_000,
@@ -118,10 +118,15 @@ export default function AgentsPage() {
});
const boards = useMemo(
() => (boardsQuery.data?.status === 200 ? boardsQuery.data.data : []),
() =>
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
[boardsQuery.data]
);
const agents = useMemo(() => agentsQuery.data?.data ?? [], [agentsQuery.data]);
const agents = useMemo(
() =>
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
[agentsQuery.data]
);
const deleteMutation = useDeleteAgentApiV1AgentsAgentIdDelete<
ApiError,
@@ -133,10 +138,18 @@ export default function AgentsPage() {
await queryClient.cancelQueries({ queryKey: agentsKey });
const previous =
queryClient.getQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey);
if (previous) {
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(agent) => agent.id !== agentId
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listAgentsApiV1AgentsGetResponse>(agentsKey, {
...previous,
data: previous.data.filter((agent) => agent.id !== agentId),
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
});
}
return { previous };

View File

@@ -16,8 +16,6 @@ import {
useListGatewaysApiV1GatewaysGet,
} from "@/api/generated/gateways/gateways";
import type { BoardRead, BoardUpdate } from "@/api/generated/model";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { BoardGoalPanel } from "@/components/BoardGoalPanel";
import { BoardOnboardingChat } from "@/components/BoardOnboardingChat";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -72,7 +70,7 @@ export default function EditBoardPage() {
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
@@ -105,7 +103,9 @@ export default function EditBoardPage() {
});
const gateways =
gatewaysQuery.data?.status === 200 ? gatewaysQuery.data.data : [];
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
: [];
const loadedBoard: BoardRead | null =
boardQuery.data?.status === 200 ? boardQuery.data.data : null;
const baseBoard = board ?? loadedBoard;
@@ -224,135 +224,149 @@ export default function EditBoardPage() {
</div>
<div className="p-8">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-6">
<BoardGoalPanel
board={baseBoard}
onStartOnboarding={() => setIsOnboardingOpen(true)}
/>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
<div className="space-y-6">
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
>
{resolvedBoardType !== "general" &&
baseBoard &&
!(baseBoard.goal_confirmed ?? false) ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-amber-900">
Goal needs confirmation
</p>
<p className="mt-1 text-xs text-amber-800/80">
Start onboarding to draft an objective and success
metrics.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={resolvedBoardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
variant="secondary"
onClick={() => setIsOnboardingOpen(true)}
disabled={isLoading || !baseBoard}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !baseBoard || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
Start onboarding
</Button>
</div>
</form>
</div>
<div className="space-y-6">
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
</div>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board name <span className="text-red-500">*</span>
</label>
<Input
value={resolvedName}
onChange={(event) => setName(event.target.value)}
placeholder="Board name"
disabled={isLoading || !baseBoard}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Gateway <span className="text-red-500">*</span>
</label>
<SearchableSelect
ariaLabel="Select gateway"
value={displayGatewayId}
onValueChange={setGatewayId}
options={gatewayOptions}
placeholder="Select gateway"
searchPlaceholder="Search gateways..."
emptyMessage="No gateways found."
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Board type
</label>
<Select value={resolvedBoardType} onValueChange={setBoardType}>
<SelectTrigger>
<SelectValue placeholder="Select board type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="goal">Goal</SelectItem>
<SelectItem value="general">General</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Target date
</label>
<Input
type="date"
value={resolvedTargetDate}
onChange={(event) => setTargetDate(event.target.value)}
disabled={isLoading}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Objective
</label>
<Textarea
value={resolvedObjective}
onChange={(event) => setObjective(event.target.value)}
placeholder="What should this board achieve?"
className="min-h-[120px]"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">
Success metrics (JSON)
</label>
<Textarea
value={resolvedSuccessMetrics}
onChange={(event) => setSuccessMetrics(event.target.value)}
placeholder='e.g. { "target": "Launch by week 2" }'
className="min-h-[140px] font-mono text-xs"
disabled={isLoading}
/>
<p className="text-xs text-slate-500">
Add key outcomes so the lead agent can measure progress.
</p>
{metricsError ? (
<p className="text-xs text-red-500">{metricsError}</p>
) : null}
</div>
{gateways.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<p>No gateways available. Create one in Gateways to continue.</p>
</div>
) : null}
{errorMessage ? (
<p className="text-sm text-red-500">{errorMessage}</p>
) : null}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => router.push(`/boards/${boardId}`)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading || !baseBoard || !isFormReady}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</div>
</div>
</main>

View File

@@ -28,16 +28,14 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { listAgentsApiV1AgentsGet, streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import { streamAgentsApiV1AgentsStreamGet } from "@/api/generated/agents/agents";
import {
listApprovalsApiV1BoardsBoardIdApprovalsGet,
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals";
import { getBoardApiV1BoardsBoardIdGet } from "@/api/generated/boards/boards";
import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost,
listBoardMemoryApiV1BoardsBoardIdMemoryGet,
streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet,
} from "@/api/generated/board-memory/board-memory";
import {
@@ -45,7 +43,6 @@ import {
createTaskCommentApiV1BoardsBoardIdTasksTaskIdCommentsPost,
deleteTaskApiV1BoardsBoardIdTasksTaskIdDelete,
listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGet,
listTasksApiV1BoardsBoardIdTasksGet,
streamTasksApiV1BoardsBoardIdTasksStreamGet,
updateTaskApiV1BoardsBoardIdTasksTaskIdPatch,
} from "@/api/generated/tasks/tasks";
@@ -54,6 +51,7 @@ import type {
ApprovalRead,
BoardMemoryRead,
BoardRead,
TaskCardRead,
TaskCommentRead,
TaskRead,
} from "@/api/generated/model";
@@ -61,13 +59,16 @@ import { cn } from "@/lib/utils";
type Board = BoardRead;
type TaskStatus = Exclude<TaskRead["status"], undefined>;
type TaskStatus = Exclude<TaskCardRead["status"], undefined>;
type Task = TaskRead & {
type Task = Omit<
TaskCardRead,
"status" | "priority" | "approvals_count" | "approvals_pending_count"
> & {
status: TaskStatus;
priority: string;
approvalsCount?: number;
approvalsPendingCount?: number;
approvals_count: number;
approvals_pending_count: number;
};
type Agent = AgentRead & { status: string };
@@ -78,10 +79,12 @@ type Approval = ApprovalRead & { status: string };
type BoardChatMessage = BoardMemoryRead;
const normalizeTask = (task: TaskRead): Task => ({
const normalizeTask = (task: TaskCardRead): Task => ({
...task,
status: task.status ?? "inbox",
priority: task.priority ?? "medium",
approvals_count: task.approvals_count ?? 0,
approvals_pending_count: task.approvals_pending_count ?? 0,
});
const normalizeAgent = (agent: AgentRead): Agent => ({
@@ -94,15 +97,6 @@ const normalizeApproval = (approval: ApprovalRead): Approval => ({
status: approval.status ?? "pending",
});
const approvalTaskId = (approval: Approval) => {
const payload = approval.payload ?? {};
return (
(payload as Record<string, unknown>).task_id ??
(payload as Record<string, unknown>).taskId ??
(payload as Record<string, unknown>).taskID
);
};
const priorities = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
@@ -244,31 +238,38 @@ export default function BoardDetailPage() {
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setIsApprovalsLoading(true);
setError(null);
setApprovalsError(null);
setChatError(null);
try {
const [boardResult, tasksResult, agentsResult] = await Promise.all([
getBoardApiV1BoardsBoardIdGet(boardId),
listTasksApiV1BoardsBoardIdTasksGet(boardId),
listAgentsApiV1AgentsGet(),
]);
if (boardResult.status !== 200) throw new Error("Unable to load board.");
if (tasksResult.status !== 200) throw new Error("Unable to load tasks.");
setBoard(boardResult.data);
setTasks(tasksResult.data.map(normalizeTask));
setAgents(agentsResult.data.map(normalizeAgent));
const snapshotResult = await getBoardSnapshotApiV1BoardsBoardIdSnapshotGet(
boardId,
);
if (snapshotResult.status !== 200) {
throw new Error("Unable to load board snapshot.");
}
const snapshot = snapshotResult.data;
setBoard(snapshot.board);
setTasks((snapshot.tasks ?? []).map(normalizeTask));
setAgents((snapshot.agents ?? []).map(normalizeAgent));
setApprovals((snapshot.approvals ?? []).map(normalizeApproval));
setChatMessages(snapshot.chat_messages ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
const message = err instanceof Error ? err.message : "Something went wrong.";
setError(message);
setApprovalsError(message);
setChatError(message);
} finally {
setIsLoading(false);
setIsApprovalsLoading(false);
}
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]);
}, [board, boardId, isSignedIn]);
useEffect(() => {
tasksRef.current = tasks;
@@ -294,54 +295,6 @@ export default function BoardDetailPage() {
return () => window.clearTimeout(timeout);
}, [chatMessages, isChatOpen]);
const loadApprovals = useCallback(async () => {
if (!isSignedIn || !boardId) return;
setIsApprovalsLoading(true);
setApprovalsError(null);
try {
const result = await listApprovalsApiV1BoardsBoardIdApprovalsGet(boardId);
if (result.status !== 200) throw new Error("Unable to load approvals.");
setApprovals(result.data.map(normalizeApproval));
} catch (err) {
setApprovalsError(
err instanceof Error ? err.message : "Unable to load approvals.",
);
} finally {
setIsApprovalsLoading(false);
}
}, [boardId, isSignedIn]);
useEffect(() => {
loadApprovals();
}, [boardId, isSignedIn, loadApprovals]);
const loadBoardChat = useCallback(async () => {
if (!isSignedIn || !boardId) return;
setChatError(null);
try {
const result = await listBoardMemoryApiV1BoardsBoardIdMemoryGet(boardId, {
limit: 200,
});
if (result.status !== 200) throw new Error("Unable to load board chat.");
const data = result.data;
const chatOnly = data.filter((item) => item.tags?.includes("chat"));
const ordered = chatOnly.sort((a, b) => {
const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime();
return aTime - bTime;
});
setChatMessages(ordered);
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Unable to load board chat.",
);
}
}, [boardId, isSignedIn]);
useEffect(() => {
loadBoardChat();
}, [boardId, isSignedIn, loadBoardChat]);
const latestChatTimestamp = (items: BoardChatMessage[]) => {
if (!items.length) return undefined;
const latest = items.reduce((max, item) => {
@@ -353,17 +306,18 @@ export default function BoardDetailPage() {
};
useEffect(() => {
if (!isSignedIn || !boardId) return;
if (!isSignedIn || !boardId || !board) return;
let isCancelled = false;
const abortController = new AbortController();
const connect = async () => {
try {
const since = latestChatTimestamp(chatMessagesRef.current);
const params = { is_chat: true, ...(since ? { since } : {}) };
const streamResult =
await streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGet(
boardId,
since ? { since } : undefined,
params,
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
@@ -439,7 +393,7 @@ export default function BoardDetailPage() {
}, [boardId, isSignedIn]);
useEffect(() => {
if (!isSignedIn || !boardId) return;
if (!isSignedIn || !boardId || !board) return;
let isCancelled = false;
const abortController = new AbortController();
@@ -487,7 +441,15 @@ export default function BoardDetailPage() {
}
if (eventType === "approval" && data) {
try {
const payload = JSON.parse(data) as { approval?: ApprovalRead };
const payload = JSON.parse(data) as {
approval?: ApprovalRead;
task_counts?: {
task_id?: string;
approvals_count?: number;
approvals_pending_count?: number;
};
pending_approvals_count?: number;
};
if (payload.approval) {
const normalized = normalizeApproval(payload.approval);
setApprovals((prev) => {
@@ -505,6 +467,25 @@ export default function BoardDetailPage() {
return next;
});
}
if (payload.task_counts?.task_id) {
const taskId = payload.task_counts.task_id;
setTasks((prev) => {
const index = prev.findIndex((task) => task.id === taskId);
if (index === -1) return prev;
const next = [...prev];
const current = next[index];
next[index] = {
...current,
approvals_count:
payload.task_counts?.approvals_count ??
current.approvals_count,
approvals_pending_count:
payload.task_counts?.approvals_pending_count ??
current.approvals_pending_count,
};
return next;
});
}
} catch {
// Ignore malformed payloads.
}
@@ -524,7 +505,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [boardId, isSignedIn]);
}, [board, boardId, isSignedIn]);
useEffect(() => {
if (!selectedTask) {
@@ -610,14 +591,37 @@ export default function BoardDetailPage() {
return [...prev, payload.comment as TaskComment];
});
} else if (payload.task) {
const normalizedTask = normalizeTask(payload.task);
setTasks((prev) => {
const index = prev.findIndex((item) => item.id === normalizedTask.id);
const index = prev.findIndex((item) => item.id === payload.task?.id);
if (index === -1) {
return [normalizedTask, ...prev];
const assignee = payload.task?.assigned_agent_id
? agentsRef.current.find(
(agent) => agent.id === payload.task?.assigned_agent_id,
)?.name ?? null
: null;
const created = normalizeTask({
...payload.task,
assignee,
approvals_count: 0,
approvals_pending_count: 0,
} as TaskCardRead);
return [created, ...prev];
}
const next = [...prev];
next[index] = { ...next[index], ...normalizedTask };
const existing = next[index];
const assignee = payload.task?.assigned_agent_id
? agentsRef.current.find(
(agent) => agent.id === payload.task?.assigned_agent_id,
)?.name ?? null
: null;
const updated = normalizeTask({
...existing,
...payload.task,
assignee,
approvals_count: existing.approvals_count,
approvals_pending_count: existing.approvals_pending_count,
} as TaskCardRead);
next[index] = { ...existing, ...updated };
return next;
});
}
@@ -727,7 +731,7 @@ export default function BoardDetailPage() {
isCancelled = true;
abortController.abort();
};
}, [boardId, isSignedIn]);
}, [board, boardId, isSignedIn]);
const resetForm = () => {
setTitle("");
@@ -754,7 +758,14 @@ export default function BoardDetailPage() {
});
if (result.status !== 200) throw new Error("Unable to create task.");
const created = normalizeTask(result.data);
const created = normalizeTask({
...result.data,
assignee: result.data.assigned_agent_id
? assigneeById.get(result.data.assigned_agent_id) ?? null
: null,
approvals_count: 0,
approvals_pending_count: 0,
} as TaskCardRead);
setTasks((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
@@ -829,49 +840,9 @@ export default function BoardDetailPage() {
});
}, [liveFeed]);
const pendingApprovalsByTaskId = useMemo(() => {
const map = new Map<string, number>();
approvals
.filter((approval) => approval.status === "pending")
.forEach((approval) => {
const taskId = approvalTaskId(approval);
if (!taskId || typeof taskId !== "string") return;
map.set(taskId, (map.get(taskId) ?? 0) + 1);
});
return map;
}, [approvals]);
const totalApprovalsByTaskId = useMemo(() => {
const map = new Map<string, number>();
approvals.forEach((approval) => {
const taskId = approvalTaskId(approval);
if (!taskId || typeof taskId !== "string") return;
map.set(taskId, (map.get(taskId) ?? 0) + 1);
});
return map;
}, [approvals]);
const displayTasks = useMemo(
() =>
tasks.map((task) => ({
...task,
assignee: task.assigned_agent_id
? assigneeById.get(task.assigned_agent_id)
: undefined,
approvalsCount: totalApprovalsByTaskId.get(task.id) ?? 0,
approvalsPendingCount: pendingApprovalsByTaskId.get(task.id) ?? 0,
})),
[tasks, assigneeById, pendingApprovalsByTaskId, totalApprovalsByTaskId],
);
const boardAgents = useMemo(
() => agents.filter((agent) => !boardId || agent.board_id === boardId),
[agents, boardId],
);
const assignableAgents = useMemo(
() => boardAgents.filter((agent) => !agent.is_board_lead),
[boardAgents],
() => agents.filter((agent) => !agent.is_board_lead),
[agents],
);
const hasTaskChanges = useMemo(() => {
@@ -912,10 +883,7 @@ export default function BoardDetailPage() {
const taskApprovals = useMemo(() => {
if (!selectedTask) return [];
const taskId = selectedTask.id;
return approvals.filter((approval) => {
const payloadTaskId = approvalTaskId(approval);
return payloadTaskId === taskId;
});
return approvals.filter((approval) => approval.task_id === taskId);
}, [approvals, selectedTask]);
const workingAgentIds = useMemo(() => {
@@ -935,12 +903,12 @@ export default function BoardDetailPage() {
if (agent.status === "provisioning") return 2;
return 3;
};
return [...boardAgents].sort((a, b) => {
return [...agents].sort((a, b) => {
const diff = rank(a) - rank(b);
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
}, [boardAgents, workingAgentIds]);
}, [agents, workingAgentIds]);
const loadComments = async (taskId: string) => {
if (!isSignedIn || !boardId) return;
@@ -953,7 +921,7 @@ export default function BoardDetailPage() {
taskId,
);
if (result.status !== 200) throw new Error("Unable to load comments.");
setComments(result.data);
setComments(result.data.items ?? []);
} catch (err) {
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -1059,9 +1027,20 @@ export default function BoardDetailPage() {
},
);
if (result.status !== 200) throw new Error("Unable to update task.");
const updated = normalizeTask(result.data);
const previous =
tasksRef.current.find((task) => task.id === selectedTask.id) ??
selectedTask;
const updated = normalizeTask({
...previous,
...result.data,
assignee: result.data.assigned_agent_id
? assigneeById.get(result.data.assigned_agent_id) ?? null
: null,
approvals_count: previous.approvals_count,
approvals_pending_count: previous.approvals_pending_count,
} as TaskCardRead);
setTasks((prev) =>
prev.map((task) => (task.id === updated.id ? updated : task)),
prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)),
);
setSelectedTask(updated);
if (closeOnSuccess) {
@@ -1119,6 +1098,7 @@ export default function BoardDetailPage() {
status,
assigned_agent_id:
status === "inbox" ? null : task.assigned_agent_id,
assignee: status === "inbox" ? null : task.assignee,
}
: task,
),
@@ -1130,9 +1110,17 @@ export default function BoardDetailPage() {
{ status },
);
if (result.status !== 200) throw new Error("Unable to move task.");
const updated = normalizeTask(result.data);
const updated = normalizeTask({
...currentTask,
...result.data,
assignee: result.data.assigned_agent_id
? assigneeById.get(result.data.assigned_agent_id) ?? null
: null,
approvals_count: currentTask.approvals_count,
approvals_pending_count: currentTask.approvals_pending_count,
} as TaskCardRead);
setTasks((prev) =>
prev.map((task) => (task.id === updated.id ? updated : task)),
prev.map((task) => (task.id === updated.id ? { ...task, ...updated } : task)),
);
} catch (err) {
setTasks(previousTasks);
@@ -1262,6 +1250,7 @@ export default function BoardDetailPage() {
const approvalRows = (approval: Approval) => {
const payload = approval.payload ?? {};
const taskId =
approval.task_id ??
approvalPayloadValue(payload, "task_id") ??
approvalPayloadValue(payload, "taskId") ??
approvalPayloadValue(payload, "taskID");
@@ -1499,7 +1488,7 @@ export default function BoardDetailPage() {
<>
{viewMode === "board" ? (
<TaskBoard
tasks={displayTasks}
tasks={tasks}
onTaskSelect={openComments}
onTaskMove={handleTaskMove}
/>
@@ -1512,7 +1501,7 @@ export default function BoardDetailPage() {
All tasks
</p>
<p className="text-xs text-slate-500">
{displayTasks.length} tasks in this board
{tasks.length} tasks in this board
</p>
</div>
<Button
@@ -1526,12 +1515,12 @@ export default function BoardDetailPage() {
</div>
</div>
<div className="divide-y divide-slate-100">
{displayTasks.length === 0 ? (
{tasks.length === 0 ? (
<div className="px-5 py-8 text-sm text-slate-500">
No tasks yet. Create your first task to get started.
</div>
) : (
displayTasks.map((task) => (
tasks.map((task) => (
<button
key={task.id}
type="button"
@@ -1553,10 +1542,10 @@ export default function BoardDetailPage() {
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{task.approvalsPendingCount ? (
{task.approvals_pending_count ? (
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Approval needed · {task.approvalsPendingCount}
Approval needed · {task.approvals_pending_count}
</span>
) : null}
<span

View File

@@ -37,7 +37,7 @@ export default function NewBoardPage() {
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchOnMount: "always",
@@ -59,7 +59,9 @@ export default function NewBoardPage() {
});
const gateways =
gatewaysQuery.data?.status === 200 ? gatewaysQuery.data.data : [];
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
: [];
const displayGatewayId = gatewayId || gateways[0]?.id || "";
const isLoading = gatewaysQuery.isLoading || createBoardMutation.isPending;
const errorMessage = error ?? gatewaysQuery.error?.message ?? null;

View File

@@ -53,7 +53,7 @@ export default function BoardsPage() {
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
@@ -62,15 +62,11 @@ export default function BoardsPage() {
});
const boards = useMemo(
() => (boardsQuery.data?.status === 200 ? boardsQuery.data.data : []),
() =>
boardsQuery.data?.status === 200 ? boardsQuery.data.data.items ?? [] : [],
[boardsQuery.data]
);
const sortedBoards = useMemo(
() => [...boards].sort((a, b) => a.name.localeCompare(b.name)),
[boards]
);
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
ApiError,
{ previous?: listBoardsApiV1BoardsGetResponse }
@@ -82,9 +78,17 @@ export default function BoardsPage() {
const previous =
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey);
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(board) => board.id !== boardId
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(boardsKey, {
...previous,
data: previous.data.filter((board) => board.id !== boardId),
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
});
}
return { previous };
@@ -159,7 +163,7 @@ export default function BoardsPage() {
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: sortedBoards,
data: boards,
columns,
getCoreRowModel: getCoreRowModel(),
});
@@ -191,11 +195,11 @@ export default function BoardsPage() {
Boards
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage boards and task workflows. {sortedBoards.length} board
{sortedBoards.length === 1 ? "" : "s"} total.
Manage boards and task workflows. {boards.length} board
{boards.length === 1 ? "" : "s"} total.
</p>
</div>
{sortedBoards.length > 0 ? (
{boards.length > 0 ? (
<Link
href="/boards/new"
className={buttonVariants({ size: "md", variant: "primary" })}

View File

@@ -16,11 +16,6 @@ import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
} from "@/api/generated/agents/agents";
import {
type listBoardsApiV1BoardsGetResponse,
useListBoardsApiV1BoardsGet,
} from "@/api/generated/boards/boards";
import type { AgentRead } from "@/api/generated/model";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
@@ -65,22 +60,12 @@ export default function GatewayDetailPage() {
const gateway =
gatewayQuery.data?.status === 200 ? gatewayQuery.data.data : null;
const boardsQuery = useListBoardsApiV1BoardsGet<
listBoardsApiV1BoardsGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
},
});
const agentsQuery = useListAgentsApiV1AgentsGet<
listAgentsApiV1AgentsGetResponse,
ApiError
>({
>(gatewayId ? { gateway_id: gatewayId } : undefined, {
query: {
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && gatewayId),
refetchInterval: 15_000,
},
});
@@ -103,22 +88,11 @@ export default function GatewayDetailPage() {
},
});
const agents = useMemo(() => {
const allAgents = agentsQuery.data?.data ?? [];
const boards = boardsQuery.data?.status === 200 ? boardsQuery.data.data : [];
if (!gatewayId) {
return allAgents;
}
const boardIds = new Set(
boards.filter((board) => board.gateway_id === gatewayId).map((board) => board.id),
);
if (boardIds.size === 0) {
return [];
}
return allAgents.filter(
(agent): agent is AgentRead => Boolean(agent.board_id && boardIds.has(agent.board_id)),
);
}, [agentsQuery.data, boardsQuery.data, gatewayId]);
const agents = useMemo(
() =>
agentsQuery.data?.status === 200 ? agentsQuery.data.data.items ?? [] : [],
[agentsQuery.data],
);
const status =
statusQuery.data?.status === 200 ? statusQuery.data.data : null;

View File

@@ -65,7 +65,7 @@ export default function GatewaysPage() {
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
listGatewaysApiV1GatewaysGetResponse,
ApiError
>({
>(undefined, {
query: {
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
@@ -73,7 +73,13 @@ export default function GatewaysPage() {
},
});
const gateways = useMemo(() => gatewaysQuery.data?.data ?? [], [gatewaysQuery.data]);
const gateways = useMemo(
() =>
gatewaysQuery.data?.status === 200
? gatewaysQuery.data.data.items ?? []
: [],
[gatewaysQuery.data]
);
const sortedGateways = useMemo(() => [...gateways], [gateways]);
const deleteMutation = useDeleteGatewayApiV1GatewaysGatewayIdDelete<
@@ -86,10 +92,18 @@ export default function GatewaysPage() {
await queryClient.cancelQueries({ queryKey: gatewaysKey });
const previous =
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey);
if (previous) {
if (previous && previous.status === 200) {
const nextItems = previous.data.items.filter(
(gateway) => gateway.id !== gatewayId
);
const removedCount = previous.data.items.length - nextItems.length;
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey, {
...previous,
data: previous.data.filter((gateway) => gateway.id !== gatewayId),
data: {
...previous.data,
items: nextItems,
total: Math.max(0, previous.data.total - removedCount),
},
});
}
return { previous };