返回 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 { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
5 import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
6
7 const STORAGE_KEY = "todoPanel:openStates";
8 const MAX_STORED_OPEN_STATES = 80;
9
10 function loadOpenStates(): Record<string, boolean> {
11 try {
12 const saved = localStorage.getItem(STORAGE_KEY);
13 if (!saved) return {};
14 const parsed = JSON.parse(saved) as unknown;
15 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
16 const states: Record<string, boolean> = {};
17 for (const [key, value] of Object.entries(parsed)) {
18 if (typeof value === "boolean") states[key] = value;
19 }
20 return states;
21 } catch {
22 return {};
23 }
24 }
25
26 function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
27 const states = loadOpenStates();
28 return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
29 }
30
31 function saveOpenState(stateKey: string, open: boolean): void {
32 try {
33 const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
34 entries.push([stateKey, open]);
35 const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
36 localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
37 } catch {
38 /* ignore quota errors */
39 }
40 }
41
42 // TodoPanel is the live task list pinned just above the composer — the kernel's
43 // latest todo_write call drives it, and it updates in place as the agent flips
44 // items to in_progress / completed. Each new todo batch starts collapsed so the
45 // header can show live progress and the current task without occupying extra
46 // space. Manual expand/collapse is restored only for the same batch.
47 export function TodoPanel({
48 stateKey,
49 todos,
50 onDismiss,
51 }: {
52 stateKey: string;
53 todos: Todo[];
54 onDismiss: () => void;
55 }) {
56 const t = useT();
57 const currentRef = useRef<HTMLLIElement | null>(null);
58
59 const done = todos.filter((t) => t.status === "completed").length;
60 const current = todos.find((t) => t.status === "in_progress");
61 const allDone = todos.length > 0 && done === todos.length;
62 const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
63 const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault()));
64 const wasAllDoneRef = useRef(allDone);
65
66 useEffect(() => {
67 if (allDone && !wasAllDoneRef.current) {
68 saveOpenState(stateKey, false);
69 setOpen(false);
70 }
71 wasAllDoneRef.current = allDone;
72 }, [allDone, stateKey]);
73
74 useEffect(() => {
75 if (!open) return;
76 currentRef.current?.scrollIntoView({ block: "nearest" });
77 }, [open, current?.content, current?.activeForm]);
78
79 if (todos.length === 0) return null;
80
81 return (
82 <PromptShelf
83 titleId="todo-shelf-title"
84 title={t("todo.title")}
85 badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
86 meta={summary}
87 role="region"
88 headerActions={
89 <>
90 <PromptHeaderAction
91 onClick={() => setOpen((value) => {
92 const next = !value;
93 saveOpenState(stateKey, next);
94 return next;
95 })}
96 >
97 {open ? t("common.collapse") : t("common.expand")}
98 </PromptHeaderAction>
99 {allDone && (
100 <PromptHeaderAction onClick={onDismiss}>
101 {t("common.close")}
102 </PromptHeaderAction>
103 )}
104 </>
105 }
106 >
107 {open && (
108 <ul className="todobar__list">
109 {todos.map((todo, index) => {
110 const status = normalizeTodoStatus(todo.status);
111 return (
112 <li
113 key={index}
114 ref={status === "in_progress" ? currentRef : undefined}
115 className={`todobar__item todobar__item--${status}${todo.level ? " todobar__item--sub" : ""}`}
116 >
117 <span className={`todobar__status todobar__status--${status}`}>
118 {t(todoStatusLabelKey(status))}
119 </span>
120 <span className="todobar__text">
121 {status === "in_progress" && todo.activeForm ? todo.activeForm : todo.content}
122 </span>
123 </li>
124 );
125 })}
126 </ul>
127 )}
128 </PromptShelf>
129 );
130 }
131
132 function normalizeTodoStatus(status: Todo["status"]): "pending" | "in_progress" | "completed" {
133 switch (String(status ?? "").trim()) {
134 case "completed":
135 return "completed";
136 case "in_progress":
137 return "in_progress";
138 default:
139 return "pending";
140 }
141 }
142
143 function todoStatusLabelKey(status: "pending" | "in_progress" | "completed"): "todo.pending" | "todo.inProgress" | "todo.completed" {
144 switch (status) {
145 case "completed":
146 return "todo.completed";
147 case "in_progress":
148 return "todo.inProgress";
149 default:
150 return "todo.pending";
151 }
152 }
153
153 lines Plain Text