返回 DeepSeek-Reasonix
HeartbeatPanel.tsx
根目录 / desktop / frontend / src / custom / features / heartbeat / HeartbeatPanel.tsx
1 import { ManagementPageShell } from "../../../components/ManagementPageShell";
2 import { useConfirmDialog } from "../../../components/ConfirmDialog";
3 import { useManagementT } from "../../../lib/managementLocale";
4 import { useAutomationDraftStore, automationDraftDirty, reconcileAutomationDraft } from "../../../store/automationDrafts";
5 import { useCallback, useEffect, useRef, useState } from "react";
6 import {
7 Activity,
8 Check,
9 ChevronDown,
10 ChevronRight,
11 ChevronsUpDown,
12 Circle,
13 CirclePause,
14 FolderTree,
15 Heart,
16 Lightbulb,
17 List,
18 MessageSquare,
19 MoreHorizontal,
20 Play,
21 Plus,
22 Search,
23 Trash2,
24 X,
25 } from "lucide-react";
26 import { app } from "../../../lib/bridge";
27 import { AnchoredPopover } from "../../../components/AnchoredPopover";
28 import { Tooltip } from "../../../components/Tooltip";
29 import {
30 heartbeatListTasks,
31 heartbeatMutateTasks,
32 heartbeatTriggerNow,
33 heartbeatGenerateID,
34 } from "./heartbeat.bridge";
35 import type { HeartbeatTask } from "./heartbeat.types";
36 import { useHeartbeatT, type HeartbeatTranslator } from "./heartbeat.i18n";
37 // 样式跟着组件走:App.tsx 通过 React.lazy 按需加载本组件,CSS 在此动态
38 // 静态导入:Vite 保证 CSS 在模块 evaluate 前注入 DOM,避免首次访问自动化页
39 // 无样式闪烁(FOUC)。node 单测通过 css-stub-register.mjs 的 loader hook 解析。
40 import "./heartbeat.css";
41 import { formatInterval, formatTaskNextRun, prepareTasksByNextRun } from "./heartbeat.presentation";
42 import { TaskEditor } from "./HeartbeatTaskEditor";
43 import { CirclePlaySolid } from "./HeartbeatShared";
44 export { changeHeartbeatFrequency, cronToInterval, heartbeatNextRunAt, intervalToCron, nextCycleRunAt, prepareTasksByNextRun } from "./heartbeat.presentation";
45 export { heartbeatBuildCycleInterval } from "./HeartbeatCycleEditor";
46 export { TaskEditor } from "./HeartbeatTaskEditor";
47 export { mergeEngineRunState } from "./HeartbeatShared";
48
49 interface HeartbeatPanelProps {
50 active?: boolean; onBack?: () => void;
51 onOpenTopic?: (scope: string, workspaceRoot: string, topicId: string) => void;
52 }
53
54 export function HeartbeatView({ onOpenTopic, active = true, onBack = () => {} }: HeartbeatPanelProps) {
55 const t = useHeartbeatT();
56 const m = useManagementT();
57 const entries = useAutomationDraftStore((state) => state.entries);
58 const drafts = useAutomationDraftStore.getState;
59 const { confirm, dialog, dismiss } = useConfirmDialog();
60 const [loadError, setLoadError] = useState(false);
61 const [operationError, setOperationError] = useState(false);
62 const [tasks, setTasks] = useState<HeartbeatTask[]>([]);
63 const [loading, setLoading] = useState(false);
64 const [mutationError, setMutationError] = useState(false);
65 const editorIntent = useRef(0);
66 const [editing, setEditing] = useState<HeartbeatTask | null>(null);
67 const [searchQuery, setSearchQuery] = useState("");
68 const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
69 const [scopeFilter, setScopeFilter] = useState<string>("all");
70 const [scopeFilterOpen, setScopeFilterOpen] = useState(false);
71 const scopeFilterRef = useRef<HTMLButtonElement>(null);
72 const [expandedProjects, setExpandedProjects] = useState<Set<string> | null>(null);
73 const [workspaceMap, setWorkspaceMap] = useState<Record<string, string>>({});
74 // Left list view: flat (default) or grouped by project scope.
75 const [listView, setListView] = useState<"flat" | "grouped">("grouped");
76 // Detail panel: hidden by default (the list fills the pane, like ChatGPT),
77 // opens on task click with a 50/50 split and a draggable divider.
78 const [detailOpen, setDetailOpen] = useState(false);
79 // 分割线拖拽宽度持久化:localStorage 缓存上次拖拽比例(30-70),
80 // 重新打开面板/切换视图后恢复,无需每次重拖。
81 const [listWidthPct, setListWidthPct] = useState(() => {
82 try {
83 const raw = localStorage.getItem("reasonix-heartbeat-list-width");
84 const cached = raw === null ? 40 : Number(raw);
85 return Number.isFinite(cached) ? Math.min(70, Math.max(30, cached)) : 40;
86 } catch {
87 return 40;
88 }
89 });
90 // ── 自定义滚动条:隐藏系统滚动条,DOM 直接驱动 + rAF 帧同步 ──
91 const listRef = useRef<HTMLDivElement>(null);
92 const thumbRef = useRef<HTMLDivElement>(null);
93 const scrollbarRef = useRef<HTMLDivElement>(null);
94
95 useEffect(() => {
96 const list = listRef.current;
97 const thumb = thumbRef.current;
98 const track = scrollbarRef.current;
99 if (!list || !thumb || !track) return;
100
101 // 滚动条自动隐藏:滚动/拖动时显示,停止 1.2s 后淡出
102 let hideTimer = 0;
103 const show = () => {
104 track.classList.add("heartbeat-scrollbar--visible");
105 window.clearTimeout(hideTimer);
106 hideTimer = window.setTimeout(() => {
107 track.classList.remove("heartbeat-scrollbar--visible");
108 }, 1200);
109 };
110
111 const update = () => {
112 const { scrollTop, scrollHeight, clientHeight } = list;
113 if (scrollHeight <= clientHeight + 1) {
114 thumb.style.display = "none";
115 return;
116 }
117 thumb.style.display = "block";
118 // 轨道(.heartbeat-scrollbar)覆盖整个 left 列,高度 ≠ 列表可视区;
119 // thumb 尺寸/行程按轨道实际高度计算,保证滚到底时 thumb 也到底。
120 const trackHeight = track.clientHeight;
121 const height = Math.max(24, (clientHeight / scrollHeight) * trackHeight);
122 const maxTop = trackHeight - height;
123 const top = maxTop <= 0 ? 0 : (scrollTop / (scrollHeight - clientHeight)) * maxTop;
124 thumb.style.height = `${Math.round(height)}px`;
125 thumb.style.top = `${Math.round(top)}px`;
126 };
127
128 // rAF 节流:滚动/尺寸变化时在下一帧同步一次滑块位置
129 let raf = 0;
130 const schedule = () => {
131 if (!raf) raf = requestAnimationFrame(() => { raf = 0; update(); });
132 };
133 // 滚动(含拖动 thumb 触发的 scroll)时显示滚动条并重置隐藏计时
134 const onScroll = () => {
135 show();
136 schedule();
137 };
138
139 update();
140 list.addEventListener("scroll", onScroll, { passive: true });
141 const ro = new ResizeObserver(schedule);
142 ro.observe(list);
143
144 // 内容变化后 scrollHeight 更新,下一帧校准(含首次挂载)
145 const t = window.setTimeout(update, 50);
146 return () => {
147 if (raf) cancelAnimationFrame(raf);
148 window.clearTimeout(t);
149 window.clearTimeout(hideTimer);
150 list.removeEventListener("scroll", onScroll);
151 ro.disconnect();
152 };
153 }, [tasks]);
154
155 const onScrollThumbMouseDown = useCallback((e: React.MouseEvent) => {
156 e.preventDefault();
157 const list = listRef.current;
158 const track = scrollbarRef.current;
159 if (!list || !track) return;
160 // 拖动开始即显示滚动条,拖动过程中保持可见(scroll 事件会重置隐藏计时)
161 track.classList.add("heartbeat-scrollbar--visible");
162 const startY = e.clientY;
163 const startScroll = list.scrollTop;
164 const { scrollHeight, clientHeight } = list;
165 // 与 update() 一致:thumb 行程按轨道实际高度映射
166 const trackHeight = track.clientHeight;
167 const height = Math.max(24, (clientHeight / scrollHeight) * trackHeight);
168 const maxTop = trackHeight - height;
169 const maxScroll = scrollHeight - clientHeight;
170 const ratio = maxTop > 0 ? maxScroll / maxTop : 1;
171 const onMove = (ev: MouseEvent) => {
172 list.scrollTop = startScroll + (ev.clientY - startY) * ratio;
173 };
174 const onUp = () => {
175 document.removeEventListener("mousemove", onMove);
176 document.removeEventListener("mouseup", onUp);
177 };
178 document.addEventListener("mousemove", onMove);
179 document.addEventListener("mouseup", onUp);
180 }, []);
181
182 const listGeneration = useRef(0);
183 const loadTasks = useCallback(async (): Promise<HeartbeatTask[] | null> => {
184 const generation = ++listGeneration.current;
185 setLoading(true);
186 try {
187 const [taskList, wsList] = await Promise.all([
188 heartbeatListTasks(),
189 app.ListWorkspaces(),
190 ]);
191 if (generation !== listGeneration.current) return null;
192 setTasks(taskList);
193 drafts().reconcile(taskList);
194 setLoadError(false);
195 const map: Record<string, string> = {};
196 if (wsList) {
197 wsList.forEach((ws) => { if (ws.path) map[ws.path] = ws.name; });
198 }
199 setWorkspaceMap(map);
200 return taskList;
201 } catch {
202 if (generation === listGeneration.current) setLoadError(true);
203 return null;
204 } finally {
205 if (generation === listGeneration.current) setLoading(false);
206 }
207 }, []);
208
209 useEffect(() => { if (active) void loadTasks(); else { editorIntent.current++; dismiss(); } }, [active, loadTasks, dismiss]);
210
211 const save = useCallback(
212 async (mutate: (current: HeartbeatTask[]) => HeartbeatTask[]): Promise<HeartbeatTask[] | null> => {
213 try {
214 const persisted = await heartbeatMutateTasks(mutate);
215 ++listGeneration.current;
216 setLoading(false);
217 setTasks(persisted);
218 drafts().reconcile(persisted);
219 setMutationError(false);
220 return persisted;
221 } catch {
222 setMutationError(true);
223 // A concurrent external edit wins; reload the authoritative config so
224 // the list cannot continue from a stale baseline. The editor keeps its
225 // local draft and reports the failure instead of pretending it saved.
226 await loadTasks();
227 return null;
228 }
229 },
230 [loadTasks],
231 );
232
233 const openUnsavedDraft = useCallback((task: HeartbeatTask, intent: number) => {
234 if (drafts().entries[task.id]) task = { ...task, id: `hb-${Date.now()}-${Math.random().toString(36).slice(2)}` };
235 drafts().ensure(task, true);
236 if (editorIntent.current === intent) { setEditing(task); setDetailOpen(true); }
237 }, []);
238
239 const handleAdd = useCallback(async () => {
240 const intent = ++editorIntent.current;
241 let id = `hb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
242 try {
243 id = await heartbeatGenerateID();
244 } catch {
245 // 生成失败时使用本地 fallback id,仍可正常打开新建详情
246 }
247 // 不设 createdAt:isNew=true,详情显示项目字段、删除按钮禁用
248 openUnsavedDraft({
249 id,
250 title: "",
251 prompt: "",
252 interval: "30m",
253 enabled: true,
254 approvalMode: "workspace-write",
255 newConversationEachRun: false,
256 notifyChannels: false,
257 }, intent);
258 }, [openUnsavedDraft]);
259
260 const handleAddToScope = useCallback(async (scopeKey: string) => {
261 const intent = ++editorIntent.current;
262 let id = `hb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
263 try {
264 id = await heartbeatGenerateID();
265 } catch { // 生成失败时使用本地 fallback id
266 }
267 const isProject = scopeKey !== "global";
268 openUnsavedDraft({
269 id,
270 title: "",
271 prompt: "",
272 interval: "30m",
273 enabled: true,
274 scope: isProject ? "project" : "global",
275 workspaceRoot: isProject ? scopeKey : "",
276 }, intent);
277 }, [openUnsavedDraft]);
278
279 // 建议区:用预设内容打开未保存草稿,由用户确认后再创建。
280 // 动态推荐:建议只在"对应标题的任务不存在"时显示——添加后消失,删除后恢复。
281 const handleAddSuggestion = useCallback(
282 async (sug: Suggestion) => {
283 const intent = ++editorIntent.current;
284 let id = `hb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
285 try {
286 id = await heartbeatGenerateID();
287 } catch {
288 // 生成失败时使用本地 fallback id
289 }
290 // 推荐卡片只打开编辑器,不直接创建并启用任务:预置 prompt 可能涉及
291 // 读取对话/扫描本地文件/访问网络等敏感操作,默认禁用并仅可查看,
292 // 由用户在编辑器中确认作用域与权限后再启用。
293 const task: HeartbeatTask = {
294 id,
295 title: sug.title,
296 prompt: sug.prompt,
297 interval: sug.interval,
298 enabled: false,
299 approvalMode: "read-only",
300 newConversationEachRun: false,
301 notifyChannels: false,
302 scope: "global",
303 workspaceRoot: "",
304 };
305 openUnsavedDraft(task, intent);
306 },
307 [openUnsavedDraft],
308 );
309
310 const handleEdit = useCallback((task: HeartbeatTask) => {
311 editorIntent.current++;
312 drafts().ensure(task);
313 setEditing(task);
314 setDetailOpen(true);
315 }, []);
316
317 // 列表行点击状态图标切换任务启用/暂停(即时保存)
318 const handleToggle = useCallback(async (task: HeartbeatTask) => {
319 drafts().ensure(task);
320 const version = drafts().begin(task.id);
321 if (version === null) return false;
322 const saved = await save((current) => current.map((item) => item.id === task.id ? { ...item, enabled: !item.enabled } : item));
323 drafts().settle(task.id, version, saved !== null);
324 return saved !== null;
325 }, [save]);
326
327 const handleDelete = useCallback(async (id: string) => {
328 const entry = drafts().entries[id];
329 if (entry?.busy) return false;
330 const name = entry?.draft.title || tasks.find((task) => task.id === id)?.title || t("heartbeat.untitled");
331 if (!await confirm({ title: m("deleteTask"), message: m("deleteTaskDescription", { name }), confirmLabel: m("confirm"), cancelLabel: m("cancel"), tone: "danger" })) return false;
332 const original = tasks.find((task) => task.id === id);
333 if (original) drafts().ensure(original);
334 const version = drafts().begin(id);
335 if (version === null) return false;
336 const saved = await save((current) => current.filter((task) => task.id !== id));
337 drafts().settle(id, version, saved !== null);
338 if (!saved) return false;
339 drafts().remove(id);
340 setEditing((previous) => previous?.id === id ? null : previous);
341 return true;
342 }, [save, tasks, t, m, confirm]);
343
344 // 任务行 ⋯ 菜单(仅删除任务):单个 popover,anchor 动态指向当前点击的按钮
345 const [menuTaskId, setMenuTaskId] = useState<string | null>(null);
346 const menuAnchorRef = useRef<HTMLButtonElement | null>(null);
347 useEffect(() => { if (!active) { setMenuTaskId(null); setScopeFilterOpen(false); } }, [active]);
348
349 const handleTrigger = useCallback(async (id: string) => {
350 const task = tasks.find((item) => item.id === id); if (!task) return;
351 drafts().ensure(task);
352 const version = drafts().begin(id); if (version === null) return;
353 setOperationError(false);
354 let success = false;
355 try { await heartbeatTriggerNow(id); success = true; await loadTasks(); }
356 catch { setOperationError(true); }
357 finally { drafts().settle(id, version, success); }
358 }, [tasks, loadTasks]);
359
360 const handleSaveEdit = useCallback(async (input: HeartbeatTask) => {
361 let task = input;
362 drafts().ensure(task, !task.createdAt);
363 if (!drafts().entries[task.id].baseline && tasks.some((item) => item.id === task.id)) {
364 const oldId = task.id;
365 let id = await heartbeatGenerateID().catch(() => "");
366 if (!id || tasks.some((item) => item.id === id) || drafts().entries[id]) id = `hb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
367 const latest = drafts().entries[oldId];
368 if (!latest || latest.busy) return false;
369 task = { ...latest.draft, id };
370 drafts().ensure(task, true); drafts().remove(oldId);
371 const replacement = task;
372 setEditing((current) => current?.id === oldId ? replacement : current);
373 }
374 const entry = drafts().entries[task.id];
375 if (entry.missing || entry.conflicts.length) return false;
376 const version = drafts().begin(task.id);
377 if (version === null) return false;
378 const saved = await save((current) => {
379 const existing = current.find((item) => item.id === task.id);
380 if (!entry.baseline && existing) throw new Error("Automation ID conflict");
381 const merged = reconcileAutomationDraft(entry, existing);
382 if (merged.missing || merged.conflicts.length) throw new Error("Automation changed elsewhere");
383 const persisted = { ...merged.draft, createdAt: merged.draft.createdAt || Date.now() };
384 return existing ? current.map((item) => item.id === task.id ? persisted : item) : [...current, persisted];
385 });
386 const persisted = saved?.find((item) => item.id === task.id);
387 drafts().finish(task.id, version, persisted);
388 return Boolean(persisted);
389 }, [save, tasks]);
390
391 const saveAsNew = useCallback(async (task: HeartbeatTask) => {
392 const version = drafts().begin(task.id);
393 if (version === null) return false;
394 let id = await heartbeatGenerateID().catch(() => "");
395 if (!id || tasks.some((item) => item.id === id) || drafts().entries[id]) id = `hb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
396 const copy = { ...task, id, enabled: false, createdAt: undefined, topicId: undefined, lastRunAt: undefined, runHistory: [] };
397 drafts().ensure(copy, true);
398 const saved = await handleSaveEdit(copy);
399 drafts().settle(task.id, version, saved);
400 if (saved) {
401 drafts().discard(task.id);
402 setEditing((current) => current?.id === task.id ? drafts().entries[id].draft : current);
403 } else { setEditing((current) => current?.id === task.id ? copy : current); }
404 return saved;
405 }, [handleSaveEdit, tasks]);
406
407 const onDividerMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
408 e.preventDefault();
409 const splitEl = e.currentTarget.parentElement;
410 if (!splitEl) return;
411 const rect = splitEl.getBoundingClientRect();
412 const onMove = (ev: MouseEvent) => {
413 const pct = ((ev.clientX - rect.left) / rect.width) * 100;
414 const clamped = Math.min(70, Math.max(30, pct));
415 setListWidthPct(clamped);
416 // 拖拽过程中同步缓存(最后一次 onMove 即松手时的值,无需在 onUp 再写)
417 try {
418 localStorage.setItem("reasonix-heartbeat-list-width", String(clamped));
419 } catch {
420 // Storage may be unavailable in hardened webviews; in-memory state still works.
421 }
422 };
423 const onUp = () => {
424 document.removeEventListener("mousemove", onMove);
425 document.removeEventListener("mouseup", onUp);
426 document.body.style.cursor = "";
427 document.body.style.userSelect = "";
428 };
429 document.body.style.cursor = "col-resize";
430 document.body.style.userSelect = "none";
431 document.addEventListener("mousemove", onMove);
432 document.addEventListener("mouseup", onUp);
433 }, []);
434
435 const scopeFilterLabel = (filter: string, map: Record<string, string>): string => {
436 if (filter === "all") return t("heartbeat.filterAllProjects");
437 if (filter === "global") return t("heartbeat.scopeGlobal");
438 return map[filter] || filter.split("/").pop() || filter;
439 };
440
441 return (
442 <ManagementPageShell active={active} onBack={onBack} title={t("heartbeat.heroTitle")} description={t("heartbeat.heroSubtitle")}
443 actions={<><button className="btn btn--small" disabled={loading} onClick={() => void loadTasks()}>{m("refresh")}</button><button className="btn btn--primary btn--small" onClick={() => void handleAdd()}><Plus size={14} />{t("heartbeat.addTask")}</button></>}>
444 {loadError && <div className="management-notice" role="alert">{m("loadFailed")}<button className="btn btn--small" onClick={() => void loadTasks()}>{m("retry")}</button></div>}
445 {operationError && <div className="management-notice" role="alert">{m("operationFailed")}</div>}
446 <div className="heartbeat-page" data-detail={detailOpen}>
447 <div className={`heartbeat-split${detailOpen ? " heartbeat-split--detail-open" : ""}`}>
448 {/* ── Left column: task list(含列表区头部工具栏) ── */}
449 <div className={`heartbeat-split__left${detailOpen ? "" : " heartbeat-split__left--full"}`} style={{ width: detailOpen ? `${listWidthPct}%` : "100%" }}>
450 <div className="heartbeat-toolbar">
451 <div className="heartbeat-status-tabs" role="tablist" aria-label={t("heartbeat.filterStatus")}>
452 {(["all", "enabled", "disabled"] as const).map((key) => (
453 <button
454 key={key}
455 type="button"
456 role="tab"
457 aria-selected={statusFilter === key}
458 className={`heartbeat-status-tabs__tab${statusFilter === key ? " heartbeat-status-tabs__tab--on" : ""}`}
459 onClick={() => setStatusFilter(key)}
460 >
461 {key === "all" ? t("heartbeat.filterAll") : key === "enabled" ? t("heartbeat.filterEnabled") : t("heartbeat.filterDisabled")}
462 </button>
463 ))}
464 </div>
465 <div className="heartbeat-toolbar__view" style={{ marginLeft: "auto" }}>
466 {true && (
467 <div className="heartbeat-scope-filter">
468 <button
469 ref={scopeFilterRef}
470 className="heartbeat-toolbar__btn heartbeat-toolbar__btn--select"
471 type="button"
472 onClick={() => setScopeFilterOpen((v) => !v)}
473 >
474 <span>{scopeFilterLabel(scopeFilter, workspaceMap)}</span>
475 <ChevronsUpDown size={12} />
476 </button>
477 <AnchoredPopover
478 open={scopeFilterOpen}
479 anchorRef={scopeFilterRef}
480 onClose={() => setScopeFilterOpen(false)}
481 className="heartbeat-filter-menu"
482 placement="bottom"
483 >
484 <div className="heartbeat-filter-menu__list" role="listbox">
485 <button
486 className={`heartbeat-filter-menu__option${scopeFilter === "all" ? " heartbeat-filter-menu__option--selected" : ""}`}
487 role="option"
488 aria-selected={scopeFilter === "all"}
489 type="button"
490 onClick={() => { setScopeFilter("all"); setScopeFilterOpen(false); }}
491 >
492 <span>{t("heartbeat.filterAllProjects")}</span>
493 {scopeFilter === "all" && <Check size={12} className="heartbeat-filter-menu__check" />}
494 </button>
495 <button
496 className={`heartbeat-filter-menu__option${scopeFilter === "global" ? " heartbeat-filter-menu__option--selected" : ""}`}
497 role="option"
498 aria-selected={scopeFilter === "global"}
499 type="button"
500 onClick={() => { setScopeFilter("global"); setScopeFilterOpen(false); }}
501 >
502 <span>{t("heartbeat.scopeGlobal")}</span>
503 {scopeFilter === "global" && <Check size={12} className="heartbeat-filter-menu__check" />}
504 </button>
505 {(() => {
506 const seen = new Set<string>();
507 const items: { value: string; label: string }[] = [];
508 for (const task of tasks) {
509 const key = task.scope !== "project" || !task.workspaceRoot ? "global" : task.workspaceRoot;
510 if (seen.has(key)) continue;
511 seen.add(key);
512 if (key !== "global") {
513 items.push({
514 value: key,
515 label: workspaceMap[key] || key.split("/").pop() || key,
516 });
517 }
518 }
519 return items.map((item) => (
520 <button
521 key={item.value}
522 className={`heartbeat-filter-menu__option${scopeFilter === item.value ? " heartbeat-filter-menu__option--selected" : ""}`}
523 role="option"
524 aria-selected={scopeFilter === item.value}
525 type="button"
526 onClick={() => { setScopeFilter(item.value); setScopeFilterOpen(false); }}
527 >
528 <span>{item.label}</span>
529 {scopeFilter === item.value && <Check size={12} className="heartbeat-filter-menu__check" />}
530 </button>
531 ));
532 })()}
533 </div>
534 </AnchoredPopover>
535 </div>
536 )}
537 <button
538 className="heartbeat-toolbar__btn heartbeat-toolbar__btn--icon"
539 type="button"
540 onClick={() => setListView(listView === "flat" ? "grouped" : "flat")}
541 title={listView === "flat" ? t("heartbeat.viewGrouped") : t("heartbeat.viewFlat")}
542 >
543 {listView === "flat" ? <FolderTree size={14} /> : <List size={14} />}
544 </button>
545
546 </div>
547 </div>
548
549 <div className="heartbeat-split__list-wrap">
550 <div className="heartbeat-list-search">
551 <Search size={13} className="heartbeat-list-search__icon" />
552 <input
553 className="heartbeat-list-search__input"
554 value={searchQuery}
555 onChange={(e) => setSearchQuery(e.target.value)}
556 onKeyDown={(e) => {
557 if (e.key === "Escape") setSearchQuery("");
558 }}
559 placeholder={t("heartbeat.searchPlaceholder")}
560 />
561 {searchQuery && (
562 <button className="heartbeat-list-search__clear" onClick={() => setSearchQuery("")}>
563 <X size={12} />
564 </button>
565 )}
566 </div>
567 <div className="heartbeat-split__list" ref={listRef}>
568 {Object.values(entries).filter((entry) => !entry.baseline || entry.missing).length > 0 && <section className="automation-draft-list"><h3>{m("drafts")}</h3>{Object.values(entries).filter((entry) => !entry.baseline || entry.missing).map((entry) => <button className="btn" key={entry.draft.id} onClick={() => handleEdit(entry.draft)}>{entry.draft.title || t("heartbeat.untitled")} <span>{m("unsaved")}</span></button>)}</section>}
569 {(() => {
570 const now = Date.now();
571 const filtered = prepareTasksByNextRun(
572 tasks.filter((task) => {
573 if (statusFilter === "enabled" && !task.enabled) return false;
574 if (statusFilter === "disabled" && task.enabled) return false;
575 if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
576 if (scopeFilter === "global" && (task.scope === "project" && task.workspaceRoot)) return false;
577 if (scopeFilter !== "all" && scopeFilter !== "global") {
578 if (task.scope !== "project" || task.workspaceRoot !== scopeFilter) return false;
579 }
580 return true;
581 }),
582 now,
583 );
584
585 // Group tasks by scope
586 const groups = new Map<string, (typeof filtered)[number][]>();
587 for (const entry of filtered) {
588 const { task } = entry;
589 const key = task.scope === "project" && task.workspaceRoot
590 ? task.workspaceRoot : "global";
591 if (!groups.has(key)) groups.set(key, []);
592 groups.get(key)!.push(entry);
593 }
594
595 const sortedGroups = Array.from(groups.entries()).sort(([a], [b]) => {
596 if (a === "global") return -1;
597 if (b === "global") return 1;
598 return (workspaceMap[a] || a).localeCompare(workspaceMap[b] || b);
599 });
600
601 const toggleProject = (key: string) => {
602 setExpandedProjects((prev) => {
603 if (prev === null) {
604 // All groups are currently expanded (null = all). The
605 // first click must collapse the clicked group while
606 // keeping every other group expanded, so seed the set
607 // with all groups minus the clicked one.
608 const next = new Set<string>();
609 for (const [k] of groups) {
610 if (k !== key) next.add(k);
611 }
612 return next;
613 }
614 const next = new Set(prev);
615 if (next.has(key)) next.delete(key);
616 else next.add(key);
617 return next;
618 });
619 };
620
621 const isGroupExpanded = (key: string): boolean => {
622 if (expandedProjects === null) return true;
623 return expandedProjects.has(key);
624 };
625
626 return loading ? (
627 <div className="heartbeat-empty">
628 <Heart size={24} className="heartbeat-pulse" />
629 <span>{t("workspace.loading")}</span>
630 </div>
631 ) : loadError && tasks.length === 0 ? null : filtered.length === 0 ? (
632 tasks.length === 0 ? (
633 <div className="heartbeat-empty heartbeat-empty--guided">
634 <Heart size={28} />
635 <span>{m("noTasks")}</span>
636 <button className="heartbeat-btn heartbeat-btn--primary" type="button" onClick={handleAdd}>
637 <Plus size={13} />
638 {t("heartbeat.addTask")}
639 </button>
640 </div>
641 ) : (
642 <div className="heartbeat-empty">
643 <Heart size={24} />
644 <span>{t("heartbeat.noMatchingTasks")}</span><button className="btn btn--small" onClick={() => { setSearchQuery(""); setStatusFilter("all"); setScopeFilter("all"); }}>{m("clearFilters")}</button>
645 </div>
646 )
647 ) : listView === "flat" ? (
648 <div className="worktree-tree heartbeat-flat">
649 {filtered.map(({ task, nextRunAt }) => {
650 const isSelected = detailOpen && editing?.id === task.id;
651 const nextRun = formatTaskNextRun(nextRunAt, now, t);
652 const scopeLabel = task.scope === "project" && task.workspaceRoot
653 ? (workspaceMap[task.workspaceRoot] || task.workspaceRoot.split("/").pop() || task.workspaceRoot)
654 : t("heartbeat.scopeGlobal");
655 return (
656 <div
657 key={task.id}
658 className={`worktree-node worktree-node--task${task.enabled ? "" : " worktree-node--paused"}${isSelected ? " worktree-node--selected" : ""}`}
659 style={{ paddingLeft: "21px" }}
660 onClick={() => handleEdit(task)}
661 >
662 <div className="worktree-node__main">
663 <span className="worktree-node__marker">
664 <Tooltip
665 label={task.enabled ? t("heartbeat.clickPause") : t("heartbeat.clickStart")}
666 side="top"
667 delay={60}
668 >
669 <button
670 className={`worktree-node__toggle${task.enabled ? " worktree-node__toggle--on" : ""}`}
671 type="button"
672 onClick={(e) => { e.stopPropagation(); void handleToggle(task); }}
673 >
674 {task.enabled ? (
675 <>
676 <Circle className="worktree-node__toggle-circle" size={15} strokeWidth={2.4} />
677 <CirclePause className="worktree-node__toggle-hover" size={15} strokeWidth={2.4} />
678 </>
679 ) : (
680 <CirclePlaySolid size={15} />
681 )}
682 </button>
683 </Tooltip>
684 </span>
685 <button type="button" className="worktree-node__label automation-task-select" onClick={(event) => { event.stopPropagation(); handleEdit(task); }}>{task.title || t("heartbeat.untitled")}{entries[task.id] && automationDraftDirty(entries[task.id]) && <small className="automation-dirty"> · {m("unsaved")}</small>}</button>
686 <span className="worktree-node__actions">
687 <button
688 className="worktree-node__action-btn"
689 type="button"
690 onClick={(e) => { e.stopPropagation(); void handleTrigger(task.id); }}
691 title={t("heartbeat.runNow")}
692 >
693 <Play size={14} strokeWidth={1.9} />
694 </button>
695 <button
696 className="worktree-node__action-btn"
697 type="button"
698 disabled={!task.topicId}
699 onClick={(e) => {
700 e.stopPropagation();
701 if (task.topicId && onOpenTopic) {
702 onOpenTopic(task.scope || "global", task.workspaceRoot || "", task.topicId);
703 }
704 }}
705 title={task.topicId ? t("heartbeat.openTopic") : ""}
706 >
707 <MessageSquare size={14} strokeWidth={1.9} />
708 </button>
709 <button
710 className="worktree-node__action-btn"
711 type="button"
712 onClick={(e) => {
713 e.stopPropagation();
714 menuAnchorRef.current = e.currentTarget;
715 setMenuTaskId(task.id);
716 }}
717 title={t("common.moreActions")}
718 >
719 <MoreHorizontal size={14} strokeWidth={1.9} />
720 </button>
721 </span>
722 </div>
723 <div className="worktree-node__meta">
724 <span className="worktree-node__scope-tag">{scopeLabel}</span>
725 <span className="worktree-node__interval">{formatInterval(task.interval, t)}{nextRun ? ` · ${nextRun}` : ""}</span>
726 </div>
727 </div>
728 );
729 })}
730 </div>
731 ) : (
732 <div className="worktree-tree">
733 {sortedGroups.map(([key, groupTasks]) => {
734 const isExpanded = isGroupExpanded(key);
735 const label = key === "global"
736 ? t("heartbeat.scopeGlobal")
737 : workspaceMap[key] || key.split("/").pop() || key;
738
739 return (
740 <div key={key}>
741 {/* ── Group header (depth 0: 8px indent) ── */}
742 <div
743 className={`worktree-node worktree-node--scope`}
744 style={{ paddingLeft: "8px" }}
745 onClick={() => toggleProject(key)}
746 >
747 <span className="worktree-node__icon">
748 {isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
749 </span>
750 <span className="worktree-node__label">{label}</span>
751 <span className="worktree-node__scope-add" onClick={(e) => { e.stopPropagation(); void handleAddToScope(key); }} title={t("heartbeat.addTaskToScope", { name: label })}>
752 <Plus size={12} strokeWidth={2.5} />
753 </span>
754 </div>
755
756 {/* ── Tasks under group (depth 1: 14 + 16 = 30px indent) ── */}
757 {isExpanded && groupTasks.map(({ task, nextRunAt }) => {
758 const isSelected = detailOpen && editing?.id === task.id;
759 const nextRun = formatTaskNextRun(nextRunAt, now, t);
760 return (
761 <div
762 key={task.id}
763 className={`worktree-node worktree-node--task${task.enabled ? "" : " worktree-node--paused"}${isSelected ? " worktree-node--selected" : ""}`}
764 style={{ paddingLeft: "21px" }}
765 onClick={() => handleEdit(task)}
766 >
767 <div className="worktree-node__main">
768 <span className="worktree-node__marker">
769 <Tooltip
770 label={task.enabled ? t("heartbeat.clickPause") : t("heartbeat.clickStart")}
771 side="top"
772 delay={60}
773 >
774 <button
775 className={`worktree-node__toggle${task.enabled ? " worktree-node__toggle--on" : ""}`}
776 type="button"
777 onClick={(e) => { e.stopPropagation(); void handleToggle(task); }}
778 >
779 {task.enabled ? (
780 <>
781 <Circle className="worktree-node__toggle-circle" size={15} strokeWidth={2.4} />
782 <CirclePause className="worktree-node__toggle-hover" size={15} strokeWidth={2.4} />
783 </>
784 ) : (
785 <CirclePlaySolid size={15} />
786 )}
787 </button>
788 </Tooltip>
789 </span>
790 <button type="button" className="worktree-node__label automation-task-select" onClick={(event) => { event.stopPropagation(); handleEdit(task); }}>{task.title || t("heartbeat.untitled")}{entries[task.id] && automationDraftDirty(entries[task.id]) && <small className="automation-dirty"> · {m("unsaved")}</small>}</button>
791 <span className="worktree-node__actions">
792 <button
793 className="worktree-node__action-btn"
794 onClick={(e) => { e.stopPropagation(); void handleTrigger(task.id); }}
795 title={t("heartbeat.runNow")}
796 >
797 <Play size={14} strokeWidth={1.9} />
798 </button>
799 <button
800 className="worktree-node__action-btn"
801 type="button"
802 disabled={!task.topicId}
803 onClick={(e) => {
804 e.stopPropagation();
805 if (task.topicId && onOpenTopic) {
806 onOpenTopic(task.scope || "global", task.workspaceRoot || "", task.topicId);
807 }
808 }}
809 title={task.topicId ? t("heartbeat.openTopic") : ""}
810 >
811 <MessageSquare size={14} strokeWidth={1.9} />
812 </button>
813 <button
814 className="worktree-node__action-btn"
815 type="button"
816 onClick={(e) => {
817 e.stopPropagation();
818 menuAnchorRef.current = e.currentTarget;
819 setMenuTaskId(task.id);
820 }}
821 title={t("common.moreActions")}
822 >
823 <MoreHorizontal size={14} strokeWidth={1.9} />
824 </button>
825 </span>
826 </div>
827 <div className="worktree-node__meta">
828 <span className="worktree-node__interval">{formatInterval(task.interval, t)}{nextRun ? ` · ${nextRun}` : ""}</span>
829 </div>
830 </div>
831 );
832 })}
833 </div>
834 );
835 })}
836 </div>
837 );
838 })()}
839 {statusFilter === "all" && !searchQuery && scopeFilter === "all" && suggestions(t).some((sug) => !tasks.some((task) => task.title === sug.title)) && (
840 <div className="heartbeat-suggestions">
841 <div className="heartbeat-suggestions__header">
842 <Lightbulb size={13} />
843 <span>{t("heartbeat.suggestions")}</span>
844 <span className="heartbeat-suggestions__hint">{t("heartbeat.suggestionsHint")}</span>
845 </div>
846 {suggestions(t)
847 .filter((sug) => !tasks.some((task) => task.title === sug.title))
848 .map((sug) => (
849 <button
850 key={sug.id}
851 className="heartbeat-suggestion"
852 type="button"
853 onClick={() => void handleAddSuggestion(sug)}
854 >
855 <div className="heartbeat-suggestion__main">
856 <span className="heartbeat-suggestion__title">{sug.title}</span>
857 <span className="heartbeat-suggestion__freq">{sug.freqLabel}</span>
858 </div>
859 <span className="heartbeat-suggestion__desc">{sug.desc}</span>
860 </button>
861 ))}
862 </div>
863 )}
864 </div>
865 </div>
866 <div className={`heartbeat-scrollbar${detailOpen ? "" : " heartbeat-scrollbar--edge"}`} aria-hidden="true" ref={scrollbarRef}>
867 <div
868 ref={thumbRef}
869 className="heartbeat-scrollbar__thumb"
870 onMouseDown={onScrollThumbMouseDown}
871 />
872 </div>
873 </div>
874
875 {/* 任务行 ⋯ 菜单:仅删除任务 */}
876 <AnchoredPopover
877 open={menuTaskId !== null}
878 anchorRef={menuAnchorRef}
879 onClose={() => setMenuTaskId(null)}
880 className="heartbeat-task-menu"
881 placement="bottom"
882 >
883 <button
884 className="heartbeat-task-menu__item heartbeat-task-menu__item--danger"
885 type="button"
886 onClick={() => {
887 if (menuTaskId) void handleDelete(menuTaskId);
888 setMenuTaskId(null);
889 }}
890 >
891 <Trash2 size={13} />
892 <span>{t("common.delete")}</span>
893 </button>
894 </AnchoredPopover>
895
896 {/* ── Vertical divider (draggable, visible only when detail open) ── */}
897 {detailOpen && (
898 <div className="heartbeat-split__divider" onMouseDown={onDividerMouseDown} />
899 )}
900
901 {/* ── Right column: detail / editor (ChatGPT-style, opens on task click) ── */}
902 {detailOpen && (
903 <div className="heartbeat-split__right">
904 <button className="btn btn--small management-list-back" onClick={() => setDetailOpen(false)}>{m("listBack")}</button>
905 {editing && ((searchQuery && !editing.title.toLowerCase().includes(searchQuery.toLowerCase())) || (statusFilter === "enabled" && !editing.enabled) || (statusFilter === "disabled" && editing.enabled) || (scopeFilter !== "all" && (editing.scope === "project" ? editing.workspaceRoot : "global") !== scopeFilter)) && <div className="management-notice">{m("filteredDetail")}<button className="btn btn--small" onClick={() => { setSearchQuery(""); setScopeFilter("all"); setStatusFilter("all"); }}>{m("clearFilters")}</button></div>}
906 {editing ? (
907 <TaskEditor key={editing.id} task={entries[editing.id]?.baseline ?? editing} onSave={handleSaveEdit}
908 onToggleEnabled={() => handleToggle(entries[editing.id]?.baseline ?? editing)} onSaveAsNew={saveAsNew}
909 onDelete={() => handleDelete(editing.id)}
910 onDiscard={() => { const id = editing.id; drafts().discard(id); if (!drafts().entries[id]) { setEditing(null); setDetailOpen(false); } }}
911 onCloseDetail={() => setDetailOpen(false)} onOpenTopic={onOpenTopic} onTrigger={handleTrigger} />
912 ) : (
913 <div className="heartbeat-split__empty">
914 <div className="heartbeat-split__empty-inner">
915 <Activity size={28} />
916 <span>{t("heartbeat.selectTask")}</span>
917
918 </div>
919 </div>
920 )}
921 </div>
922 )}
923 </div>
924 {mutationError && (
925 <div className="heartbeat-editor__save-notice" role="alert">
926 <span className="heartbeat-editor__save-error">{t("heartbeat.saveFailed")}</span>
927 </div>
928 )}
929 </div>
930 {active && dialog}
931 </ManagementPageShell>
932 );
933 }
934
935 // ── Cycle Editor ──────────────────────────────────────────────────────────────
936
937
938 // ── 建议区:预填的自动化任务草稿 ──
939 interface Suggestion {
940 id: string;
941 title: string;
942 desc: string;
943 prompt: string;
944 interval: string;
945 freqLabel: string;
946 }
947
948 function suggestions(t: HeartbeatTranslator): Suggestion[] {
949 return [
950 {
951 id: "daily-review",
952 title: t("heartbeat.sugDailyReview"),
953 desc: t("heartbeat.sugDailyReviewDesc"),
954 prompt: t("heartbeat.sugDailyReviewPrompt"),
955 interval: "24h|daily@20:00",
956 freqLabel: t("heartbeat.sugDailyReviewFreq"),
957 },
958 {
959 id: "product-update",
960 title: t("heartbeat.sugProductUpdate"),
961 desc: t("heartbeat.sugProductUpdateDesc"),
962 prompt: t("heartbeat.sugProductUpdatePrompt"),
963 interval: "24h|daily@12:00",
964 freqLabel: t("heartbeat.sugProductUpdateFreq"),
965 },
966 {
967 id: "downloads-report",
968 title: t("heartbeat.sugDownloads"),
969 desc: t("heartbeat.sugDownloadsDesc"),
970 prompt: t("heartbeat.sugDownloadsPrompt"),
971 interval: "168h|weekly:fri@16:00",
972 freqLabel: t("heartbeat.sugDownloadsFreq"),
973 },
974 ];
975 }
976
977 // ── Editor ─────────────────────────────────────────────────────────────────────
978
978 lines Plain Text