Skip to content

Commit 8769aa7

Browse files
committed
feat(dashboard): task board section in mission workbench
Polls /api/control/missions/:id/board; shows per-task status/outcome/deps, utilization summary, worker links, and inline cancel/accept actions. Hidden for missions without a board.
1 parent 7f66670 commit 8769aa7

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"use client";
2+
3+
// Task board section for the mission workbench. Polls the server-owned board
4+
// (see backend api::control::board) and renders task status, dependencies,
5+
// and worker links. Renders nothing for missions without a board.
6+
7+
import { useCallback, useEffect, useRef, useState } from "react";
8+
import { KanbanSquare, X } from "lucide-react";
9+
import { cn } from "@/lib/utils";
10+
import {
11+
cancelBoardTask,
12+
getMissionBoard,
13+
postBoardTaskVerdict,
14+
type BoardTask,
15+
type MissionBoard,
16+
} from "@/lib/api";
17+
18+
const POLL_MS = 5000;
19+
20+
function statusGlyph(task: BoardTask): { glyph: string; cls: string } {
21+
switch (task.status) {
22+
case "running":
23+
return { glyph: "●", cls: "text-emerald-400" };
24+
case "settled":
25+
return task.outcome === "blocked"
26+
? { glyph: "◆", cls: "text-orange-300" }
27+
: task.outcome === "failed"
28+
? { glyph: "✗", cls: "text-red-400" }
29+
: { glyph: "◐", cls: "text-amber-300" };
30+
case "accepted":
31+
return { glyph: "✓", cls: "text-white/30" };
32+
case "failed":
33+
return { glyph: "✗", cls: "text-red-400" };
34+
case "cancelled":
35+
return { glyph: "–", cls: "text-white/25" };
36+
default:
37+
return { glyph: "○", cls: "text-white/40" };
38+
}
39+
}
40+
41+
function rowTextClass(task: BoardTask): string {
42+
switch (task.status) {
43+
case "accepted":
44+
return "text-white/30 line-through";
45+
case "cancelled":
46+
return "text-white/25 line-through";
47+
case "running":
48+
return "text-white/80";
49+
case "settled":
50+
return task.outcome === "success" ? "text-amber-200/90" : "text-orange-300";
51+
case "failed":
52+
return "text-red-400";
53+
default:
54+
return "text-white/55";
55+
}
56+
}
57+
58+
export function MissionTaskBoard({
59+
missionId,
60+
onViewMission,
61+
}: {
62+
missionId: string;
63+
onViewMission: (missionId: string) => void;
64+
}) {
65+
const [board, setBoard] = useState<MissionBoard | null>(null);
66+
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
67+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
68+
69+
const refresh = useCallback(async () => {
70+
try {
71+
const b = await getMissionBoard(missionId);
72+
setBoard(b);
73+
} catch {
74+
// Board endpoint unavailable (old backend) or mission gone: hide section.
75+
setBoard(null);
76+
}
77+
}, [missionId]);
78+
79+
useEffect(() => {
80+
setBoard(null);
81+
void refresh();
82+
timerRef.current = setInterval(() => void refresh(), POLL_MS);
83+
return () => {
84+
if (timerRef.current) clearInterval(timerRef.current);
85+
};
86+
}, [refresh]);
87+
88+
if (!board || board.tasks.length === 0) return null;
89+
const u = board.utilization;
90+
const done = u.accepted + u.cancelled;
91+
92+
return (
93+
<div className="mt-3 border-t border-white/[0.06] pt-2.5">
94+
<div className="mb-1.5 flex items-center justify-between">
95+
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
96+
<KanbanSquare className="h-3 w-3" />
97+
Task board
98+
</div>
99+
<span
100+
className="text-[10px] tabular-nums text-white/35"
101+
title={`${u.running} running · ${u.pending} pending · ${u.settled} awaiting verdict · ${u.failed} failed · capacity ${u.max_parallel}`}
102+
>
103+
{u.running > 0 && (
104+
<span className="text-emerald-400">{u.running} running · </span>
105+
)}
106+
{u.settled > 0 && (
107+
<span className="text-amber-300">{u.settled} verdict · </span>
108+
)}
109+
{done}/{u.total} done
110+
</span>
111+
</div>
112+
<ul className="space-y-0.5">
113+
{board.tasks.map((task) => {
114+
const { glyph, cls } = statusGlyph(task);
115+
const clickable = !!task.worker_mission_id;
116+
const blockedBy =
117+
task.status === "pending" && task.depends_on.length > 0
118+
? task.depends_on.join(", ")
119+
: null;
120+
return (
121+
<li key={task.id} className="group flex items-start gap-1.5">
122+
<span className={cn("mt-px shrink-0 text-[11px]", cls)}>
123+
{glyph}
124+
</span>
125+
<button
126+
type="button"
127+
disabled={!clickable}
128+
onClick={() =>
129+
task.worker_mission_id &&
130+
onViewMission(task.worker_mission_id)
131+
}
132+
title={[
133+
`${task.task_key} · ${task.status}${task.outcome ? ` (${task.outcome})` : ""} · ${task.backend}${task.model_override ? ` ${task.model_override}` : ""}${task.attempts > 1 ? ` · attempt ${task.attempts}` : ""}`,
134+
task.result_digest ? `\n${task.result_digest.slice(0, 500)}` : "",
135+
].join("")}
136+
className={cn(
137+
"min-w-0 flex-1 text-left text-[11px] leading-snug",
138+
rowTextClass(task),
139+
clickable && "hover:underline",
140+
)}
141+
>
142+
<span className="font-mono text-[10px] opacity-70">
143+
{task.task_key}
144+
</span>{" "}
145+
{task.title}
146+
{blockedBy && (
147+
<span className="text-[10px] text-white/30">{blockedBy}</span>
148+
)}
149+
</button>
150+
{(task.status === "pending" || task.status === "running") && (
151+
<button
152+
type="button"
153+
title="Cancel task"
154+
disabled={busyTaskId === task.id}
155+
onClick={async () => {
156+
setBusyTaskId(task.id);
157+
try {
158+
await cancelBoardTask(task.id);
159+
await refresh();
160+
} catch {
161+
// surfaced by next poll
162+
} finally {
163+
setBusyTaskId(null);
164+
}
165+
}}
166+
className="hidden shrink-0 rounded p-0.5 text-white/30 hover:bg-white/[0.06] hover:text-red-400 group-hover:block"
167+
>
168+
<X className="h-3 w-3" />
169+
</button>
170+
)}
171+
{task.status === "settled" && (
172+
<button
173+
type="button"
174+
title="Accept result (boss normally judges; this overrides)"
175+
disabled={busyTaskId === task.id}
176+
onClick={async () => {
177+
setBusyTaskId(task.id);
178+
try {
179+
await postBoardTaskVerdict(task.id, "accept");
180+
await refresh();
181+
} catch {
182+
// surfaced by next poll
183+
} finally {
184+
setBusyTaskId(null);
185+
}
186+
}}
187+
className="hidden shrink-0 rounded p-0.5 text-white/30 hover:bg-white/[0.06] hover:text-emerald-400 group-hover:block"
188+
>
189+
190+
</button>
191+
)}
192+
</li>
193+
);
194+
})}
195+
</ul>
196+
</div>
197+
);
198+
}

dashboard/src/app/control/components/MissionWorkbenchPanel.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { inferMissionRole } from "@/lib/mission-role";
2626
import type { Mission, MissionStatus } from "@/lib/api";
2727
import type { MissionStateSummary } from "../events-reducer";
2828
import { missionStatusDotClass, missionStatusLabel } from "./common";
29+
import { MissionTaskBoard } from "./MissionTaskBoard";
2930

3031
export function MissionWorkbenchPanel({
3132
mission,
@@ -399,6 +400,13 @@ export function MissionWorkbenchPanel({
399400
</div>
400401
)}
401402

403+
{mission && (
404+
<MissionTaskBoard
405+
missionId={mission.id}
406+
onViewMission={onViewMission}
407+
/>
408+
)}
409+
402410
{childMissions.length > 0 && (
403411
<div className="mt-3 border-t border-white/[0.06] pt-2.5">
404412
<div className="mb-1.5 flex items-center justify-between">

dashboard/src/lib/api.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,85 @@ export async function postControlMessage(
445445
return res.json();
446446
}
447447

448+
// ---- Mission task board ----------------------------------------------------
449+
450+
export type BoardTaskStatus =
451+
| "pending"
452+
| "running"
453+
| "settled"
454+
| "accepted"
455+
| "failed"
456+
| "cancelled";
457+
458+
export type BoardTaskOutcome = "success" | "blocked" | "failed";
459+
460+
export interface BoardTask {
461+
id: string;
462+
boss_mission_id: string;
463+
task_key: string;
464+
title: string;
465+
prompt: string;
466+
backend: string;
467+
model_override?: string;
468+
model_effort?: string;
469+
working_directory?: string;
470+
depends_on: string[];
471+
status: BoardTaskStatus;
472+
outcome?: BoardTaskOutcome;
473+
worker_mission_id?: string;
474+
attempts: number;
475+
result_digest?: string;
476+
notes?: string;
477+
created_at: string;
478+
updated_at: string;
479+
}
480+
481+
export interface BoardUtilization {
482+
pending: number;
483+
running: number;
484+
settled: number;
485+
accepted: number;
486+
failed: number;
487+
cancelled: number;
488+
total: number;
489+
max_parallel: number;
490+
}
491+
492+
export interface MissionBoard {
493+
tasks: BoardTask[];
494+
utilization: BoardUtilization;
495+
}
496+
497+
export async function getMissionBoard(
498+
missionId: string,
499+
): Promise<MissionBoard> {
500+
const res = await apiFetch(`/api/control/missions/${missionId}/board`);
501+
if (!res.ok) throw new Error("Failed to fetch mission board");
502+
return res.json();
503+
}
504+
505+
export async function postBoardTaskVerdict(
506+
taskId: string,
507+
action: "accept" | "reject",
508+
feedback?: string,
509+
): Promise<BoardTask> {
510+
const res = await apiFetch(`/api/control/board/tasks/${taskId}/verdict`, {
511+
method: "POST",
512+
headers: { "Content-Type": "application/json" },
513+
body: JSON.stringify({ action, feedback }),
514+
});
515+
if (!res.ok) throw new Error(await res.text());
516+
return res.json();
517+
}
518+
519+
export async function cancelBoardTask(taskId: string): Promise<BoardTask> {
520+
const res = await apiFetch(`/api/control/board/tasks/${taskId}/cancel`, {
521+
method: "POST",
522+
});
523+
if (!res.ok) throw new Error(await res.text());
524+
return res.json();
525+
}
526+
448527
export async function postControlToolResult(payload: {
449528
tool_call_id: string;
450529
name: string;

0 commit comments

Comments
 (0)