返回 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 { 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
494 lines Plain Text