返回 DeepSeek-Reasonix
TodoPanel.tsx
根目录 / desktop / frontend / src / components / TodoPanel.tsx
1 import { useEffect, useRef, useState } from "react";
2 import { useT } from "../lib/i18n";
3 import type { Todo } from "../lib/tools";
4 import {
5 shouldOpenTodoPanelByDefault,
6 todoPresentationStatus,
7 type TodoPresentationStatus,
8 } from "../lib/todoVisibility";
9 import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
10
11 const STORAGE_KEY = "todoPanel:openStates";
12 const MAX_STORED_OPEN_STATES = 80;
13 const COMPLETION_HOLD_MS = 900;
14 const COMPLETION_FADE_MS = 240;
15
16 function loadOpenStates(): Record<string, boolean> {
17 try {
18 const saved = localStorage.getItem(STORAGE_KEY);
19 if (!saved) return {};
20 const parsed = JSON.parse(saved) as unknown;
21 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
22 const states: Record<string, boolean> = {};
23 for (const [key, value] of Object.entries(parsed)) {
24 if (typeof value === "boolean") states[key] = value;
25 }
26 return states;
27 } catch {
28 return {};
29 }
30 }
31
32 function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
33 const states = loadOpenStates();
34 return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
35 }
36
37 function saveOpenState(stateKey: string, open: boolean): void {
38 try {
39 const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
40 entries.push([stateKey, open]);
41 const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
42 localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
43 } catch {
44 /* ignore quota errors */
45 }
46 }
47
48 // TodoPanel is the live task list pinned just above the composer — the kernel's
49 // latest todo_write call drives it, and it updates in place as the agent flips
50 // items to in_progress / completed. Each new todo batch starts collapsed so the
51 // header can show live progress and the current task without occupying extra
52 // space. A batch that just reached completion briefly shows its final count,
53 // then leaves the composer shelf; the transcript tool call remains.
54 // Manual expand/collapse is restored only for the same batch.
55 export function TodoPanel({
56 stateKey,
57 todos,
58 running,
59 pendingPrompt,
60 onContinue,
61 onDismiss,
62 }: {
63 stateKey: string;
64 todos: Todo[];
65 running: boolean;
66 pendingPrompt: boolean;
67 onContinue?: () => void;
68 onDismiss: () => void;
69 }) {
70 const t = useT();
71 const currentRef = useRef<HTMLLIElement | null>(null);
72
73 const done = todos.filter((t) => t.status === "completed").length;
74 const current = todos.find((t) => t.status === "in_progress");
75 const allDone = todos.length > 0 && done === todos.length;
76 const summary = current?.content || todos[todos.length - 1]?.content || "";
77 const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault()));
78 const [visible, setVisible] = useState(!allDone);
79
80 useEffect(() => {
81 if (!allDone) {
82 setVisible(true);
83 return;
84 }
85 if (!visible) return;
86
87 saveOpenState(stateKey, false);
88 setOpen(false);
89 const dismissTimer = window.setTimeout(() => setVisible(false), COMPLETION_HOLD_MS + COMPLETION_FADE_MS);
90 return () => {
91 window.clearTimeout(dismissTimer);
92 };
93 }, [allDone, stateKey, visible]);
94
95 useEffect(() => {
96 if (!open) return;
97 currentRef.current?.scrollIntoView({ block: "nearest" });
98 }, [open, current?.content]);
99
100 if (todos.length === 0 || !visible) return null;
101
102 return (
103 <PromptShelf
104 className={allDone ? "todo-exit" : undefined}
105 titleId="todo-shelf-title"
106 title={t("todo.title")}
107 badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
108 meta={summary}
109 role="region"
110 cardClassName="prompt-shelf--todo"
111 cardCollapsible
112 collapsed={!open}
113 onToggleCollapse={() => setOpen((value) => {
114 const next = !value;
115 saveOpenState(stateKey, next);
116 return next;
117 })}
118 headerActions={allDone ? (
119 <PromptHeaderAction onClick={onDismiss}>
120 {t("common.close")}
121 </PromptHeaderAction>
122 ) : current && !running && !pendingPrompt && onContinue ? (
123 <PromptHeaderAction onClick={onContinue}>
124 {t("todo.continue")}
125 </PromptHeaderAction>
126 ) : undefined}
127 >
128 {open && (
129 <ul className="todobar__list">
130 {todos.map((todo, index) => {
131 const sourceStatus = normalizeTodoStatus(todo.status);
132 const status = todoPresentationStatus(sourceStatus, { running, pendingPrompt });
133 return (
134 <li
135 key={index}
136 ref={sourceStatus === "in_progress" ? currentRef : undefined}
137 className={`todobar__item todobar__item--${status}`}
138 >
139 <span className={`todobar__status todobar__status--${status}`}>
140 {t(todoStatusLabelKey(status))}
141 </span>
142 <span className="todobar__text">
143 {todo.content}
144 </span>
145 </li>
146 );
147 })}
148 </ul>
149 )}
150 </PromptShelf>
151 );
152 }
153
154 function normalizeTodoStatus(status: Todo["status"]): "pending" | "in_progress" | "completed" {
155 switch (String(status ?? "").trim()) {
156 case "completed":
157 return "completed";
158 case "in_progress":
159 return "in_progress";
160 default:
161 return "pending";
162 }
163 }
164
165 function todoStatusLabelKey(status: TodoPresentationStatus): "todo.pending" | "todo.inProgress" | "status.runtimePendingPrompt" | "todo.paused" | "todo.completed" {
166 switch (status) {
167 case "completed":
168 return "todo.completed";
169 case "in_progress":
170 return "todo.inProgress";
171 case "waiting":
172 return "status.runtimePendingPrompt";
173 case "paused":
174 return "todo.paused";
175 default:
176 return "todo.pending";
177 }
178 }
179
179 lines Plain Text