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