| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { |
| 3 | AlertCircle, |
| 4 | ChevronDown, |
| 5 | ChevronRight, |
| 6 | Clock, |
| 7 | List, |
| 8 | Loader2, |
| 9 | RotateCw, |
| 10 | X, |
| 11 | XCircle, |
| 12 | } from "lucide-react"; |
| 13 | import { app } from "../lib/bridge"; |
| 14 | import { useT } from "../lib/i18n"; |
| 15 | import type { TaskEvent, TaskSnapshot } from "../lib/types"; |
| 16 | |
| 17 | // --- helpers --- |
| 18 | |
| 19 | const STATE_CONFIG: Record< |
| 20 | string, |
| 21 | { key: "queued" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "stale"; color: string; dot: string } |
| 22 | > = { |
| 23 | queued: { key: "queued", color: "#6b7280", dot: "⚪" }, |
| 24 | running: { key: "running", color: "#3b82f6", dot: "🔵" }, |
| 25 | waiting: { key: "waiting", color: "#f59e0b", dot: "🟡" }, |
| 26 | succeeded: { key: "succeeded", color: "#22c55e", dot: "🟢" }, |
| 27 | failed: { key: "failed", color: "#ef4444", dot: "🔴" }, |
| 28 | cancelled: { key: "cancelled", color: "#9ca3af", dot: "⏹️" }, |
| 29 | stale: { key: "stale", color: "#d4d4d8", dot: "⬜" }, |
| 30 | }; |
| 31 | |
| 32 | function stateConfig(state: string, t: ReturnType<typeof useT>) { |
| 33 | const config = STATE_CONFIG[state]; |
| 34 | return config |
| 35 | ? { ...config, label: t(`task.state.${config.key}` as never) } |
| 36 | : { label: state, color: "#6b7280", dot: "❓" }; |
| 37 | } |
| 38 | |
| 39 | function runtimeConfig(state: string | undefined, t: ReturnType<typeof useT>) { |
| 40 | switch (state) { |
| 41 | case "alive": |
| 42 | return { label: t("task.runtime.live"), color: "#22c55e" }; |
| 43 | case "exited": |
| 44 | return { label: t("task.runtime.exited"), color: "#9ca3af" }; |
| 45 | default: |
| 46 | return { label: t("task.runtime.unknown"), color: "#6b7280" }; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | function safeStateClass(state: string): string { |
| 51 | // Sanitize state for use in CSS class names — only allow word chars. |
| 52 | return state.replace(/[^a-zA-Z0-9_-]/g, "_"); |
| 53 | } |
| 54 | |
| 55 | function elapsed(iso: string): string { |
| 56 | if (!iso) return "—"; |
| 57 | const ms = Date.now() - new Date(iso).getTime(); |
| 58 | if (isNaN(ms) || ms < 0) return "—"; |
| 59 | const s = Math.floor(ms / 1000); |
| 60 | if (s < 60) return `${s}s`; |
| 61 | const m = Math.floor(s / 60); |
| 62 | if (m < 60) return `${m}m`; |
| 63 | const h = Math.floor(m / 60); |
| 64 | return `${h}h`; |
| 65 | } |
| 66 | |
| 67 | function shortID(id: string): string { |
| 68 | return id.length > 8 ? id.slice(0, 8) : id; |
| 69 | } |
| 70 | |
| 71 | function eventSummary(ev: TaskEvent, t: ReturnType<typeof useT>): string { |
| 72 | if (ev.error_code) return t("task.event.error", { code: ev.error_code }); |
| 73 | switch (ev.event_type) { |
| 74 | case "state_change": |
| 75 | return t("task.event.stateChange", { state: ev.state, runtime: runtimeConfig(ev.runtime_state, t).label }); |
| 76 | case "error": |
| 77 | return ev.error_summary || t("task.error"); |
| 78 | default: |
| 79 | return ev.event_type; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // --- component --- |
| 84 | |
| 85 | const POLL_INTERVAL_MS = 5000; |
| 86 | |
| 87 | export function TaskMonitorPanel({ |
| 88 | tabID, |
| 89 | onClose, |
| 90 | onOpenSession, |
| 91 | initialOpen = false, |
| 92 | popover = false, |
| 93 | summaryMode = false, |
| 94 | }: { |
| 95 | tabID: string; |
| 96 | onClose?: () => void; |
| 97 | onOpenSession?: (tabID: string, taskID: string) => Promise<boolean> | boolean; |
| 98 | initialOpen?: boolean; |
| 99 | popover?: boolean; |
| 100 | summaryMode?: boolean; |
| 101 | }) { |
| 102 | const t = useT(); |
| 103 | const [tasks, setTasks] = useState<TaskSnapshot[]>([]); |
| 104 | const [loading, setLoading] = useState(true); |
| 105 | const [error, setError] = useState<string | null>(null); |
| 106 | const [expanded, setExpanded] = useState<Set<string>>(new Set()); |
| 107 | const [open, setOpen] = useState(initialOpen); |
| 108 | const [actionTask, setActionTask] = useState<string | null>(null); |
| 109 | const [actionError, setActionError] = useState<string | null>(null); |
| 110 | const [actionMessage, setActionMessage] = useState<string | null>(null); |
| 111 | const [pendingAction, setPendingAction] = useState<{ task: TaskSnapshot; action: "stop" | "cancel" } | null>(null); |
| 112 | |
| 113 | // Per-task event state |
| 114 | const [taskEvents, setTaskEvents] = useState<Map<string, TaskEvent[]>>( |
| 115 | () => new Map(), |
| 116 | ); |
| 117 | const [eventsLoading, setEventsLoading] = useState<Set<string>>(new Set()); |
| 118 | const [eventsError, setEventsError] = useState<Map<string, string>>( |
| 119 | () => new Map(), |
| 120 | ); |
| 121 | const eventCursors = useRef<Map<string, number>>(new Map()); |
| 122 | |
| 123 | const fetchTasks = useCallback(async () => { |
| 124 | try { |
| 125 | setError(null); |
| 126 | const list = await app.ListTasksForTab(tabID); |
| 127 | setTasks(list ?? []); |
| 128 | } catch (e) { |
| 129 | setError(String(e)); |
| 130 | } finally { |
| 131 | setLoading(false); |
| 132 | } |
| 133 | }, [tabID]); |
| 134 | |
| 135 | // Fetch events for a single task, using afterSequence for incremental load. |
| 136 | const fetchEvents = useCallback(async (taskID: string) => { |
| 137 | setEventsLoading((prev) => new Set(prev).add(taskID)); |
| 138 | setEventsError((prev) => { |
| 139 | const next = new Map(prev); |
| 140 | next.delete(taskID); |
| 141 | return next; |
| 142 | }); |
| 143 | try { |
| 144 | const cursor = eventCursors.current.get(taskID) ?? 0; |
| 145 | const events = await app.ListTaskEventsForTab(tabID, taskID, cursor); |
| 146 | if (events.length > 0) { |
| 147 | setTaskEvents((prev) => { |
| 148 | const next = new Map(prev); |
| 149 | const existing = next.get(taskID) ?? []; |
| 150 | // Merge, deduplicate by sequence |
| 151 | const seen = new Set(existing.map((e) => e.sequence)); |
| 152 | const merged = [...existing, ...events.filter((e) => !seen.has(e.sequence))]; |
| 153 | merged.sort((a, b) => a.sequence - b.sequence); |
| 154 | next.set(taskID, merged); |
| 155 | return next; |
| 156 | }); |
| 157 | // Update cursor to the max sequence |
| 158 | const maxSeq = events.reduce( |
| 159 | (max, e) => Math.max(max, e.sequence), |
| 160 | cursor, |
| 161 | ); |
| 162 | eventCursors.current.set(taskID, maxSeq); |
| 163 | } |
| 164 | } catch (e) { |
| 165 | setEventsError((prev) => { |
| 166 | const next = new Map(prev); |
| 167 | next.set(taskID, String(e)); |
| 168 | return next; |
| 169 | }); |
| 170 | } finally { |
| 171 | setEventsLoading((prev) => { |
| 172 | const next = new Set(prev); |
| 173 | next.delete(taskID); |
| 174 | return next; |
| 175 | }); |
| 176 | } |
| 177 | }, [tabID]); |
| 178 | |
| 179 | // Initial fetch + periodic polling |
| 180 | useEffect(() => { |
| 181 | fetchTasks(); |
| 182 | const interval = setInterval(() => { |
| 183 | fetchTasks(); |
| 184 | // Also refresh events for expanded tasks |
| 185 | expanded.forEach((id) => { |
| 186 | fetchEvents(id); |
| 187 | }); |
| 188 | }, POLL_INTERVAL_MS); |
| 189 | return () => clearInterval(interval); |
| 190 | }, [fetchTasks, fetchEvents, expanded]); |
| 191 | |
| 192 | const toggleTask = (id: string) => { |
| 193 | setExpanded((prev) => { |
| 194 | const next = new Set(prev); |
| 195 | if (next.has(id)) { |
| 196 | next.delete(id); |
| 197 | } else { |
| 198 | next.add(id); |
| 199 | // Load events on first expand |
| 200 | if (!taskEvents.has(id)) { |
| 201 | fetchEvents(id); |
| 202 | } |
| 203 | } |
| 204 | return next; |
| 205 | }); |
| 206 | }; |
| 207 | |
| 208 | const controlTask = async (task: TaskSnapshot, action: "stop" | "cancel" | "requeue" | "open") => { |
| 209 | if ((action === "stop" || action === "cancel") && (!pendingAction || pendingAction.task.task_id !== task.task_id || pendingAction.action !== action)) { |
| 210 | setPendingAction({ task, action }); |
| 211 | return; |
| 212 | } |
| 213 | setPendingAction(null); |
| 214 | setActionTask(task.task_id); |
| 215 | setActionError(null); |
| 216 | setActionMessage(null); |
| 217 | try { |
| 218 | if (action === "open" && onOpenSession) { |
| 219 | const opened = await onOpenSession(tabID, task.task_id); |
| 220 | if (opened) onClose?.(); |
| 221 | return; |
| 222 | } |
| 223 | const result = action === "stop" |
| 224 | ? await app.StopTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) |
| 225 | : action === "cancel" |
| 226 | ? await app.CancelTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) |
| 227 | : action === "requeue" |
| 228 | ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, `desktop-${action}-${task.task_id}-${task.version}`) |
| 229 | : await app.OpenTaskSessionForTab(tabID, task.task_id); |
| 230 | if (result.error) { |
| 231 | setActionError(`${result.error.code}: ${result.error.message}`); |
| 232 | } else if (action === "open") { |
| 233 | const sessionID = result.session_id?.trim(); |
| 234 | if (!sessionID) throw new Error("Task session is unavailable"); |
| 235 | setActionMessage(`Session: ${sessionID}`); |
| 236 | } else { |
| 237 | setActionMessage(result.idempotent ? "Already applied" : "Task updated"); |
| 238 | await fetchTasks(); |
| 239 | } |
| 240 | } catch (e) { |
| 241 | setActionError(String(e)); |
| 242 | } finally { |
| 243 | setActionTask(null); |
| 244 | } |
| 245 | }; |
| 246 | |
| 247 | const sorted = [...tasks].sort( |
| 248 | (a, b) => |
| 249 | new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), |
| 250 | ); |
| 251 | |
| 252 | return ( |
| 253 | <div className={`taskmonitor${popover ? " taskmonitor--popover" : ""}`}> |
| 254 | <div className="taskmonitor__head"> |
| 255 | <button |
| 256 | className="taskmonitor__toggle" |
| 257 | onClick={() => setOpen((v) => !v)} |
| 258 | aria-expanded={open} |
| 259 | aria-label={open ? "Collapse tasks" : "Expand tasks"} |
| 260 | > |
| 261 | {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />} |
| 262 | </button> |
| 263 | <span className="taskmonitor__title">{summaryMode ? t("summary.session") : t("summary.tasks")}</span> |
| 264 | <span className="taskmonitor__count">{tasks.length}</span> |
| 265 | <button |
| 266 | className="taskmonitor__refresh" |
| 267 | onClick={() => { |
| 268 | setLoading(true); |
| 269 | fetchTasks(); |
| 270 | }} |
| 271 | title={t("summary.refresh")} |
| 272 | aria-label={t("summary.refresh")} |
| 273 | > |
| 274 | <RotateCw size={12} /> |
| 275 | </button> |
| 276 | {onClose && ( |
| 277 | <button |
| 278 | className="taskmonitor__close" |
| 279 | onClick={onClose} |
| 280 | title={t("common.close")} |
| 281 | aria-label={t("summary.close")} |
| 282 | > |
| 283 | <X size={14} /> |
| 284 | </button> |
| 285 | )} |
| 286 | </div> |
| 287 | |
| 288 | {open && ( |
| 289 | <div className="taskmonitor__body"> |
| 290 | {summaryMode && <div className="taskmonitor__category-title">{t("summary.tasks")}</div>} |
| 291 | {actionError && <div className="taskmonitor__state taskmonitor__state--error">{actionError}</div>} |
| 292 | {actionMessage && <div className="taskmonitor__state">{actionMessage}</div>} |
| 293 | {loading && ( |
| 294 | <div className="taskmonitor__state"> |
| 295 | <Loader2 size={16} className="taskmonitor__spinner" /> |
| 296 | <span>{t("common.loading")}</span> |
| 297 | </div> |
| 298 | )} |
| 299 | |
| 300 | {error && ( |
| 301 | <div className="taskmonitor__state taskmonitor__state--error"> |
| 302 | <AlertCircle size={16} /> |
| 303 | <span>{error}</span> |
| 304 | </div> |
| 305 | )} |
| 306 | |
| 307 | {!loading && !error && sorted.length === 0 && ( |
| 308 | <div className="taskmonitor__state taskmonitor__state--empty"> |
| 309 | <Clock size={16} /> |
| 310 | <span>{t("summary.noTasks")}</span> |
| 311 | </div> |
| 312 | )} |
| 313 | |
| 314 | {!loading && |
| 315 | sorted.map((task) => { |
| 316 | const cfg = stateConfig(task.state, t); |
| 317 | const runtime = runtimeConfig(task.runtime_state, t); |
| 318 | const isOpen = expanded.has(task.task_id); |
| 319 | const terminal = |
| 320 | task.state === "succeeded" || |
| 321 | task.state === "failed" || |
| 322 | task.state === "cancelled" || |
| 323 | task.state === "stale"; |
| 324 | const evs = taskEvents.get(task.task_id) ?? []; |
| 325 | const evLoading = eventsLoading.has(task.task_id); |
| 326 | const evError = eventsError.get(task.task_id); |
| 327 | |
| 328 | return ( |
| 329 | <div |
| 330 | key={task.task_id} |
| 331 | className={`taskmonitor__task taskmonitor__task--${safeStateClass(task.state)}`} |
| 332 | > |
| 333 | <div className="taskmonitor__task-head"> |
| 334 | <button |
| 335 | className="taskmonitor__expand" |
| 336 | onClick={() => toggleTask(task.task_id)} |
| 337 | aria-expanded={isOpen} |
| 338 | aria-label={t("summary.taskLabel", { id: shortID(task.task_id), state: cfg.label })} |
| 339 | > |
| 340 | <span |
| 341 | className="taskmonitor__dot" |
| 342 | style={{ color: cfg.color }} |
| 343 | > |
| 344 | {cfg.dot} |
| 345 | </span> |
| 346 | <span className="taskmonitor__id"> |
| 347 | {shortID(task.task_id)} |
| 348 | </span> |
| 349 | <span |
| 350 | className="taskmonitor__badge" |
| 351 | style={{ |
| 352 | backgroundColor: cfg.color + "18", |
| 353 | color: cfg.color, |
| 354 | }} |
| 355 | > |
| 356 | {cfg.label} |
| 357 | </span> |
| 358 | <span |
| 359 | className="taskmonitor__runtime" |
| 360 | style={{ color: runtime.color }} |
| 361 | title="Runtime process state" |
| 362 | > |
| 363 | <span aria-hidden="true">{task.runtime_state === "alive" ? "●" : "○"}</span> |
| 364 | {runtime.label} |
| 365 | </span> |
| 366 | {terminal && ( |
| 367 | <XCircle size={12} className="taskmonitor__terminal" /> |
| 368 | )} |
| 369 | <span className="taskmonitor__time"> |
| 370 | {elapsed(task.updated_at)} |
| 371 | </span> |
| 372 | {isOpen ? ( |
| 373 | <ChevronDown size={12} /> |
| 374 | ) : ( |
| 375 | <ChevronRight size={12} /> |
| 376 | )} |
| 377 | </button> |
| 378 | </div> |
| 379 | |
| 380 | {isOpen && ( |
| 381 | <div className="taskmonitor__detail"> |
| 382 | <dl> |
| 383 | <dt>{t("summary.taskId")}</dt> |
| 384 | <dd>{task.task_id}</dd> |
| 385 | <dt>{t("summary.sessionId")}</dt> |
| 386 | <dd>{task.session_id || "—"}</dd> |
| 387 | <dt>{t("summary.state")}</dt> |
| 388 | <dd>{task.state}</dd> |
| 389 | <dt>{t("summary.runtime")}</dt> |
| 390 | <dd>{runtime.label}</dd> |
| 391 | <dt>{t("summary.updated")}</dt> |
| 392 | <dd>{new Date(task.updated_at).toLocaleString()}</dd> |
| 393 | {task.error_code && ( |
| 394 | <> |
| 395 | <dt>{t("summary.errorCode")}</dt> |
| 396 | <dd className="taskmonitor__err">{task.error_code}</dd> |
| 397 | </> |
| 398 | )} |
| 399 | {task.error_summary && ( |
| 400 | <> |
| 401 | <dt>{t("summary.detail")}</dt> |
| 402 | <dd className="taskmonitor__err-summary"> |
| 403 | {task.error_summary} |
| 404 | </dd> |
| 405 | </> |
| 406 | )} |
| 407 | </dl> |
| 408 | |
| 409 | {/* Events section */} |
| 410 | <div className="taskmonitor__events"> |
| 411 | <div className="taskmonitor__events-head"> |
| 412 | <List size={12} /> |
| 413 | <span>{t("summary.recentEvents")}</span> |
| 414 | {evs.length > 0 && ( |
| 415 | <span className="taskmonitor__events-count"> |
| 416 | {evs.length} |
| 417 | </span> |
| 418 | )} |
| 419 | </div> |
| 420 | |
| 421 | {evLoading && evs.length === 0 && ( |
| 422 | <div className="taskmonitor__state"> |
| 423 | <Loader2 |
| 424 | size={12} |
| 425 | className="taskmonitor__spinner" |
| 426 | /> |
| 427 | <span>{t("summary.loadingEvents")}</span> |
| 428 | </div> |
| 429 | )} |
| 430 | |
| 431 | {evError && ( |
| 432 | <div className="taskmonitor__state taskmonitor__state--error"> |
| 433 | <AlertCircle size={12} /> |
| 434 | <span>{evError}</span> |
| 435 | </div> |
| 436 | )} |
| 437 | |
| 438 | {!evLoading && !evError && evs.length === 0 && ( |
| 439 | <div className="taskmonitor__state taskmonitor__state--empty"> |
| 440 | <span>{t("summary.noEvents")}</span> |
| 441 | </div> |
| 442 | )} |
| 443 | |
| 444 | {evs.length > 0 && ( |
| 445 | <ul className="taskmonitor__event-list"> |
| 446 | {evs.map((ev) => ( |
| 447 | <li |
| 448 | key={ev.sequence} |
| 449 | className="taskmonitor__event" |
| 450 | > |
| 451 | <span className="taskmonitor__event-seq"> |
| 452 | #{ev.sequence} |
| 453 | </span> |
| 454 | <span className="taskmonitor__event-type"> |
| 455 | {eventSummary(ev, t)} |
| 456 | </span> |
| 457 | <span className="taskmonitor__event-time"> |
| 458 | {new Date(ev.timestamp).toLocaleTimeString()} |
| 459 | </span> |
| 460 | </li> |
| 461 | ))} |
| 462 | </ul> |
| 463 | )} |
| 464 | </div> |
| 465 | <div className="taskmonitor__actions"> |
| 466 | {(task.state === "queued" || task.state === "running" || task.state === "waiting") && ( |
| 467 | <> |
| 468 | <button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "stop")}>{t("summary.stop")}</button> |
| 469 | <button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "cancel")}>{t("summary.cancel")}</button> |
| 470 | </> |
| 471 | )} |
| 472 | {(task.state === "failed" || task.state === "stale") && ( |
| 473 | <button disabled={actionTask === task.task_id || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button> |
| 474 | )} |
| 475 | <button disabled={actionTask === task.task_id} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button> |
| 476 | </div> |
| 477 | {pendingAction?.task.task_id === task.task_id && ( |
| 478 | <div className="taskmonitor__confirm"> |
| 479 | <span>{t(pendingAction.action === "stop" ? "summary.confirmStop" : "summary.confirmCancel")}</span> |
| 480 | <button type="button" onClick={() => void controlTask(task, pendingAction.action)}>{t("common.confirm")}</button> |
| 481 | <button type="button" onClick={() => setPendingAction(null)}>{t("summary.keep")}</button> |
| 482 | </div> |
| 483 | )} |
| 484 | </div> |
| 485 | )} |
| 486 | </div> |
| 487 | ); |
| 488 | })} |
| 489 | </div> |
| 490 | )} |
| 491 | </div> |
| 492 | ); |
| 493 | } |
| 494 |