返回 DeepSeek-Reasonix
TaskMonitorPanel.tsx
根目录 / desktop / frontend / src / components / TaskMonitorPanel.tsx
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 { desktopHost } from "../lib/desktopHost";
15 import { useT } from "../lib/i18n";
16 import type { TaskEvent, TaskSnapshot } from "../lib/types";
17
18 type CatalogTask = TaskSnapshot & { __projectKey: string; __projectLabel: string; __catalogKey: string };
19
20 function hasTaskCatalogBinding(): boolean {
21 return typeof desktopHost().app?.ListTaskPage === "function";
22 }
23
24 // --- helpers ---
25
26 type TaskTimerSnapshot = TaskSnapshot & { runtime_lease_until?: string };
27
28 const STATE_CONFIG: Record<
29 string,
30 { key: "queued" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "stale"; color: string; dot: string }
31 > = {
32 queued: { key: "queued", color: "#6b7280", dot: "⚪" },
33 running: { key: "running", color: "#3b82f6", dot: "🔵" },
34 waiting: { key: "waiting", color: "#f59e0b", dot: "🟡" },
35 succeeded: { key: "succeeded", color: "#22c55e", dot: "🟢" },
36 failed: { key: "failed", color: "#ef4444", dot: "🔴" },
37 cancelled: { key: "cancelled", color: "#9ca3af", dot: "⏹️" },
38 stale: { key: "stale", color: "#d4d4d8", dot: "⬜" },
39 };
40
41 function stateConfig(state: string, t: ReturnType<typeof useT>) {
42 const config = STATE_CONFIG[state];
43 return config
44 ? { ...config, label: t(`task.state.${config.key}` as never) }
45 : { label: state, color: "#6b7280", dot: "❓" };
46 }
47
48 function runtimeConfig(state: string | undefined, t: ReturnType<typeof useT>) {
49 switch (state) {
50 case "alive":
51 return { label: t("task.runtime.live"), color: "#22c55e" };
52 case "exited":
53 return { label: t("task.runtime.exited"), color: "#9ca3af" };
54 default:
55 return { label: t("task.runtime.unknown"), color: "#6b7280" };
56 }
57 }
58
59 function safeStateClass(state: string): string {
60 // Sanitize state for use in CSS class names — only allow word chars.
61 return state.replace(/[^a-zA-Z0-9_-]/g, "_");
62 }
63
64 function isTerminalState(state: string): boolean {
65 return state === "succeeded" || state === "failed" || state === "cancelled" || state === "stale";
66 }
67
68 function isStoppableState(state: string): boolean {
69 return state === "queued" || state === "running" || state === "waiting";
70 }
71
72 function elapsed(task: TaskTimerSnapshot, nowMs: number): string {
73 if (!task.created_at) return "—";
74 const startMs = new Date(task.created_at).getTime();
75 if (task.state === "queued") return "—";
76 const live = task.runtime_state === "alive" && !isTerminalState(task.state);
77 let endMs = live ? nowMs : new Date(task.updated_at).getTime();
78 if (task.state === "stale" && task.runtime_lease_until) {
79 const leaseEndMs = new Date(task.runtime_lease_until).getTime();
80 // Stale is inferred when an alive runtime lease expires. The observer does
81 // not rewrite updated_at, so the expired lease is the best bounded end time.
82 if (!isNaN(leaseEndMs) && leaseEndMs >= startMs && leaseEndMs <= nowMs) {
83 endMs = leaseEndMs;
84 }
85 }
86 const ms = endMs - startMs;
87 if (isNaN(ms) || ms < 0) return "—";
88 const s = Math.floor(ms / 1000);
89 if (s < 60) return `${s}s`;
90 const m = Math.floor(s / 60);
91 if (m < 60) return `${m}m`;
92 const h = Math.floor(m / 60);
93 return `${h}h`;
94 }
95
96 function shortID(id: string): string {
97 return id.length > 8 ? id.slice(0, 8) : id;
98 }
99
100 function eventSummary(ev: TaskEvent, t: ReturnType<typeof useT>): string {
101 if (ev.error_code) return t("task.event.error", { code: ev.error_code });
102 switch (ev.event_type) {
103 case "state_change":
104 return t("task.event.stateChange", { state: stateConfig(ev.state, t).label, runtime: runtimeConfig(ev.runtime_state, t).label });
105 case "error":
106 return ev.error_summary || t("task.error");
107 default:
108 return ev.event_type;
109 }
110 }
111
112 // --- component ---
113
114 const POLL_INTERVAL_MS = 5000;
115
116 export function TaskMonitorPanel({
117 tabID,
118 onClose,
119 onOpenSession,
120 initialOpen = false,
121 initialScope = "session",
122 popover = false,
123 summaryMode = false,
124 }: {
125 tabID: string;
126 onClose?: () => void;
127 onOpenSession?: (tabID: string, taskID: string) => Promise<boolean> | boolean;
128 initialOpen?: boolean;
129 initialScope?: "session" | "project" | "all";
130 popover?: boolean;
131 summaryMode?: boolean;
132 }) {
133 const t = useT();
134 const [tasks, setTasks] = useState<CatalogTask[]>([]);
135 const [scope, setScope] = useState<"session" | "project" | "all">(initialScope);
136 const [query, setQuery] = useState("");
137 const [nextCursor, setNextCursor] = useState("");
138 const [indexProgress, setIndexProgress] = useState<{ indexed: number; total: number; partial: boolean }>({ indexed: 0, total: 0, partial: true });
139 const requestSeq = useRef(0);
140 const [loading, setLoading] = useState(true);
141 const [error, setError] = useState<string | null>(null);
142 const [expanded, setExpanded] = useState<Set<string>>(new Set());
143 const [open, setOpen] = useState(initialOpen);
144 const [actionTask, setActionTask] = useState<string | null>(null);
145 const [actionError, setActionError] = useState<string | null>(null);
146 const [actionMessage, setActionMessage] = useState<string | null>(null);
147 const [nowMs, setNowMs] = useState(() => Date.now());
148 const [pendingStop, setPendingStop] = useState<CatalogTask | null>(null);
149 const stopButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
150 const confirmStopRef = useRef<HTMLButtonElement | null>(null);
151
152 // Per-task event state
153 const [taskEvents, setTaskEvents] = useState<Map<string, TaskEvent[]>>(
154 () => new Map(),
155 );
156 const [eventsLoading, setEventsLoading] = useState<Set<string>>(new Set());
157 const [eventsError, setEventsError] = useState<Map<string, string>>(
158 () => new Map(),
159 );
160 const eventCursors = useRef<Map<string, number>>(new Map());
161
162 const fetchTasks = useCallback(async (cursor = "") => {
163 const seq = ++requestSeq.current;
164 try {
165 setError(null);
166 if (!hasTaskCatalogBinding()) {
167 const legacy = await app.ListTasksForTab(tabID);
168 if (seq !== requestSeq.current) return;
169 const filtered = legacy.filter((task) => !query.trim() || [task.task_id, task.session_id, task.error_code, task.error_summary].some((value) => (value || "").toLowerCase().includes(query.trim().toLowerCase())));
170 setTasks(filtered.map((task) => ({ ...task, __projectKey: "", __projectLabel: "", __catalogKey: task.task_id })));
171 setNextCursor("");
172 setIndexProgress({ indexed: filtered.length, total: filtered.length, partial: false });
173 return;
174 }
175 const page = await app.ListTaskPage({ scope, tabId: tabID, projectKey: "", states: [], query, cursor, limit: 50 });
176 if (seq !== requestSeq.current) return;
177 const decorated = (page.items ?? []).map((item) => ({ ...item.task, __projectKey: item.projectKey, __projectLabel: item.projectLabel, __catalogKey: `${item.projectKey}:${item.task.task_id}` }));
178 setTasks((current) => cursor ? [...current, ...decorated.filter((item) => !current.some((existing) => existing.__catalogKey === item.__catalogKey))] : decorated);
179 setNextCursor(page.nextCursor || "");
180 setIndexProgress({ indexed: page.status.indexed, total: page.status.total, partial: page.partial });
181 } catch (e) {
182 if (seq !== requestSeq.current) return;
183 setError(String(e));
184 } finally {
185 setLoading(false);
186 }
187 }, [query, scope, tabID]);
188
189 // Fetch events for a single task, using afterSequence for incremental load.
190 const fetchEvents = useCallback(async (task: CatalogTask) => {
191 const taskID = task.__catalogKey;
192 setEventsLoading((prev) => new Set(prev).add(taskID));
193 setEventsError((prev) => {
194 const next = new Map(prev);
195 next.delete(taskID);
196 return next;
197 });
198 try {
199 const cursor = eventCursors.current.get(taskID) ?? 0;
200 const events = hasTaskCatalogBinding()
201 ? (await app.ListTaskEventPage({ projectKey: task.__projectKey, taskId: task.task_id, after: cursor, limit: 50 })).items ?? []
202 : await app.ListTaskEventsForTab(tabID, task.task_id, cursor);
203 if (events.length > 0) {
204 setTaskEvents((prev) => {
205 const next = new Map(prev);
206 const existing = next.get(taskID) ?? [];
207 // Merge, deduplicate by sequence
208 const seen = new Set(existing.map((e) => e.sequence));
209 const merged = [...existing, ...events.filter((e) => !seen.has(e.sequence))];
210 merged.sort((a, b) => a.sequence - b.sequence);
211 next.set(taskID, merged);
212 return next;
213 });
214 // Update cursor to the max sequence
215 const maxSeq = events.reduce(
216 (max, e) => Math.max(max, e.sequence),
217 cursor,
218 );
219 eventCursors.current.set(taskID, maxSeq);
220 }
221 } catch (e) {
222 setEventsError((prev) => {
223 const next = new Map(prev);
224 next.set(taskID, String(e));
225 return next;
226 });
227 } finally {
228 setEventsLoading((prev) => {
229 const next = new Set(prev);
230 next.delete(taskID);
231 return next;
232 });
233 }
234 }, [tabID]);
235
236 // Initial fetch + periodic polling
237 useEffect(() => {
238 void fetchTasks("");
239 const interval = setInterval(() => {
240 void fetchTasks("");
241 }, POLL_INTERVAL_MS);
242 return () => clearInterval(interval);
243 }, [fetchTasks]);
244
245 // Live tasks need a ticking clock; terminal and queued tasks stay frozen at
246 // their persisted end/update time.
247 useEffect(() => {
248 if (!tasks.some((task) => task.runtime_state === "alive" && !isTerminalState(task.state))) return;
249 const interval = setInterval(() => setNowMs(Date.now()), 1000);
250 return () => clearInterval(interval);
251 }, [tasks]);
252
253 useEffect(() => {
254 if (pendingStop) confirmStopRef.current?.focus();
255 }, [pendingStop]);
256
257 useEffect(() => {
258 if (!pendingStop) return;
259 const current = tasks.find((task) => task.__catalogKey === pendingStop.__catalogKey);
260 if (!current || !isStoppableState(current.state)) setPendingStop(null);
261 }, [pendingStop, tasks]);
262
263 const dismissStopConfirmation = () => {
264 const taskKey = pendingStop?.__catalogKey;
265 setPendingStop(null);
266 if (taskKey) {
267 requestAnimationFrame(() => stopButtonRefs.current.get(taskKey)?.focus());
268 }
269 };
270
271 const toggleTask = (task: CatalogTask) => {
272 const id = task.__catalogKey;
273 setExpanded((prev) => {
274 const next = new Set(prev);
275 if (next.has(id)) {
276 next.delete(id);
277 } else {
278 next.add(id);
279 // Load events on first expand
280 if (!taskEvents.has(id)) {
281 void fetchEvents(task);
282 }
283 }
284 return next;
285 });
286 };
287
288 const controlTask = async (task: CatalogTask, action: "stop" | "requeue" | "open") => {
289 setPendingStop(null);
290 setActionTask(task.__catalogKey);
291 setActionError(null);
292 setActionMessage(null);
293 try {
294 if (action === "open" && onOpenSession && scope === "session") {
295 const opened = await onOpenSession(tabID, task.task_id);
296 if (opened) onClose?.();
297 return;
298 }
299 const request = { projectKey: task.__projectKey, taskId: task.task_id, expectedVersion: task.version, reason: "desktop request", idempotencyKey: `desktop-${action}-${task.task_id}-${task.version}` };
300 const result = hasTaskCatalogBinding()
301 ? action === "stop"
302 ? await app.StopTaskByKey(request)
303 : action === "requeue"
304 ? await app.RequeueTaskByKey(request)
305 : await app.OpenTaskSessionByKey({ projectKey: task.__projectKey, taskId: task.task_id })
306 : action === "stop"
307 ? await app.StopTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey)
308 : action === "requeue"
309 ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, request.idempotencyKey)
310 : await app.OpenTaskSessionForTab(tabID, task.task_id);
311 if (result.error) {
312 setActionError(`${result.error.code}: ${result.error.message}`);
313 } else if (action === "open") {
314 const sessionID = result.session_id?.trim();
315 if (!sessionID) throw new Error("Task session is unavailable");
316 setActionMessage(`Session: ${sessionID}`);
317 } else {
318 setActionMessage(result.idempotent ? "Already applied" : "Task updated");
319 await fetchTasks("");
320 }
321 } catch (e) {
322 setActionError(String(e));
323 } finally {
324 setActionTask(null);
325 }
326 };
327
328 const sorted = [...tasks].sort(
329 (a, b) =>
330 new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
331 );
332
333 return (
334 <div className={`taskmonitor${popover ? " taskmonitor--popover" : ""}`}>
335 <div className="taskmonitor__head">
336 <button
337 className="taskmonitor__toggle"
338 onClick={() => setOpen((v) => !v)}
339 aria-expanded={open}
340 aria-label={open ? "Collapse tasks" : "Expand tasks"}
341 >
342 {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
343 </button>
344 <span className="taskmonitor__title">{summaryMode ? t("summary.session") : t("summary.tasks")}</span>
345 <span className="taskmonitor__count">{tasks.length}</span>
346 <button
347 className="taskmonitor__refresh"
348 onClick={() => {
349 setLoading(true);
350 void fetchTasks("");
351 }}
352 title={t("summary.refresh")}
353 aria-label={t("summary.refresh")}
354 >
355 <RotateCw size={12} />
356 </button>
357 {onClose && (
358 <button
359 className="taskmonitor__close"
360 onClick={onClose}
361 title={t("common.close")}
362 aria-label={t("summary.close")}
363 >
364 <X size={14} />
365 </button>
366 )}
367 </div>
368
369 {open && (
370 <div className="taskmonitor__body">
371 {!summaryMode && (
372 <div className="taskmonitor__filters">
373 <select value={scope} onChange={(event) => setScope(event.target.value as "session" | "project" | "all")} aria-label="Task scope">
374 <option value="session">Current session</option>
375 <option value="project">Current project</option>
376 <option value="all">All projects</option>
377 </select>
378 <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter tasks" aria-label="Filter tasks" />
379 </div>
380 )}
381 {indexProgress.partial && <div className="taskmonitor__indexing">Indexing tasks ({indexProgress.indexed}/{indexProgress.total})</div>}
382 {summaryMode && <div className="taskmonitor__category-title">{t("summary.tasks")}</div>}
383 {actionError && <div className="taskmonitor__state taskmonitor__state--error">{actionError}</div>}
384 {actionMessage && <div className="taskmonitor__state">{actionMessage}</div>}
385 {loading && (
386 <div className="taskmonitor__state">
387 <Loader2 size={16} className="taskmonitor__spinner" />
388 <span>{t("common.loading")}</span>
389 </div>
390 )}
391
392 {error && (
393 <div className="taskmonitor__state taskmonitor__state--error">
394 <AlertCircle size={16} />
395 <span>{error}</span>
396 </div>
397 )}
398
399 {!loading && !error && sorted.length === 0 && (
400 <div className="taskmonitor__state taskmonitor__state--empty">
401 <Clock size={16} />
402 <span>{t("summary.noTasks")}</span>
403 </div>
404 )}
405
406 {!loading &&
407 sorted.map((task) => {
408 const cfg = stateConfig(task.state, t);
409 const runtime = runtimeConfig(task.runtime_state, t);
410 const taskKey = task.__catalogKey;
411 const isOpen = expanded.has(taskKey);
412 const terminal = isTerminalState(task.state);
413 const evs = taskEvents.get(taskKey) ?? [];
414 const evLoading = eventsLoading.has(taskKey);
415 const evError = eventsError.get(taskKey);
416
417 return (
418 <div
419 key={taskKey}
420 className={`taskmonitor__task taskmonitor__task--${safeStateClass(task.state)}`}
421 >
422 <div className="taskmonitor__task-head">
423 <button
424 className="taskmonitor__expand"
425 onClick={() => toggleTask(task)}
426 aria-expanded={isOpen}
427 aria-label={t("summary.taskLabel", { id: shortID(task.task_id), state: cfg.label })}
428 >
429 <span
430 className="taskmonitor__dot"
431 style={{ color: cfg.color }}
432 >
433 {cfg.dot}
434 </span>
435 <span className="taskmonitor__id">
436 {shortID(task.task_id)}
437 </span>
438 {scope === "all" && <span className="taskmonitor__project">{task.__projectLabel}</span>}
439 <span
440 className="taskmonitor__badge"
441 style={{
442 backgroundColor: cfg.color + "18",
443 color: cfg.color,
444 }}
445 >
446 {cfg.label}
447 </span>
448 <span
449 className="taskmonitor__runtime"
450 style={{ color: runtime.color }}
451 title="Runtime process state"
452 >
453 <span aria-hidden="true">{task.runtime_state === "alive" ? "●" : "○"}</span>
454 {runtime.label}
455 </span>
456 {terminal && (
457 <XCircle size={12} className="taskmonitor__terminal" />
458 )}
459 <span className="taskmonitor__time">
460 {elapsed(task, nowMs)}
461 </span>
462 {isOpen ? (
463 <ChevronDown size={12} />
464 ) : (
465 <ChevronRight size={12} />
466 )}
467 </button>
468 </div>
469
470 {isOpen && (
471 <div className="taskmonitor__detail">
472 <dl>
473 <dt>{t("summary.taskId")}</dt>
474 <dd>{task.task_id}</dd>
475 <dt>{t("summary.sessionId")}</dt>
476 <dd>{task.session_id || "—"}</dd>
477 <dt>{t("summary.state")}</dt>
478 <dd>{cfg.label}</dd>
479 <dt>{t("summary.runtime")}</dt>
480 <dd>{runtime.label}</dd>
481 <dt>{t("summary.updated")}</dt>
482 <dd>{new Date(task.updated_at).toLocaleString()}</dd>
483 {task.error_code && (
484 <>
485 <dt>{t("summary.errorCode")}</dt>
486 <dd className="taskmonitor__err">{task.error_code}</dd>
487 </>
488 )}
489 {task.error_summary && (
490 <>
491 <dt>{t("summary.detail")}</dt>
492 <dd className="taskmonitor__err-summary">
493 {task.error_summary}
494 </dd>
495 </>
496 )}
497 </dl>
498
499 {/* Events section */}
500 <div className="taskmonitor__events">
501 <div className="taskmonitor__events-head">
502 <List size={12} />
503 <span>{t("summary.recentEvents")}</span>
504 {evs.length > 0 && (
505 <span className="taskmonitor__events-count">
506 {evs.length}
507 </span>
508 )}
509 </div>
510
511 {evLoading && evs.length === 0 && (
512 <div className="taskmonitor__state">
513 <Loader2
514 size={12}
515 className="taskmonitor__spinner"
516 />
517 <span>{t("summary.loadingEvents")}</span>
518 </div>
519 )}
520
521 {evError && (
522 <div className="taskmonitor__state taskmonitor__state--error">
523 <AlertCircle size={12} />
524 <span>{evError}</span>
525 </div>
526 )}
527
528 {!evLoading && !evError && evs.length === 0 && (
529 <div className="taskmonitor__state taskmonitor__state--empty">
530 <span>{t("summary.noEvents")}</span>
531 </div>
532 )}
533
534 {evs.length > 0 && (
535 <ul className="taskmonitor__event-list">
536 {evs.map((ev) => (
537 <li
538 key={ev.sequence}
539 className="taskmonitor__event"
540 >
541 <span className="taskmonitor__event-seq">
542 #{ev.sequence}
543 </span>
544 <span className="taskmonitor__event-type">
545 {eventSummary(ev, t)}
546 </span>
547 <span className="taskmonitor__event-time">
548 {new Date(ev.timestamp).toLocaleTimeString()}
549 </span>
550 </li>
551 ))}
552 </ul>
553 )}
554 </div>
555 {pendingStop?.__catalogKey === taskKey ? (
556 <div
557 className="taskmonitor__confirm"
558 role="group"
559 aria-label={t("summary.confirmStop")}
560 onKeyDown={(event) => {
561 if (event.key === "Escape") {
562 event.preventDefault();
563 dismissStopConfirmation();
564 }
565 }}
566 >
567 <span className="taskmonitor__confirm-copy">{t("summary.confirmStop")}</span>
568 <div className="taskmonitor__confirm-actions">
569 <button
570 ref={confirmStopRef}
571 type="button"
572 className="taskmonitor__confirm-stop"
573 disabled={actionTask === taskKey}
574 onClick={() => void controlTask(task, "stop")}
575 >
576 {t("summary.stop")}
577 </button>
578 <button type="button" onClick={dismissStopConfirmation}>{t("summary.keep")}</button>
579 </div>
580 </div>
581 ) : (
582 <div className="taskmonitor__actions">
583 {isStoppableState(task.state) && (
584 <button
585 ref={(node) => {
586 if (node) stopButtonRefs.current.set(taskKey, node);
587 else stopButtonRefs.current.delete(taskKey);
588 }}
589 className="taskmonitor__stop"
590 disabled={actionTask === taskKey}
591 onClick={() => setPendingStop(task)}
592 >
593 {t("summary.stop")}
594 </button>
595 )}
596 {(task.state === "failed" || task.state === "stale") && (
597 <button disabled={actionTask === taskKey || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button>
598 )}
599 <button disabled={actionTask === taskKey} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button>
600 </div>
601 )}
602 </div>
603 )}
604 </div>
605 );
606 })}
607 {nextCursor && !loading && !error && (
608 <button className="taskmonitor__load-more" onClick={() => void fetchTasks(nextCursor)}>
609 Load more
610 </button>
611 )}
612 </div>
613 )}
614 </div>
615 );
616 }
617
617 lines Plain Text