|
| 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 | +} |
0 commit comments