返回 DeepSeek-Reasonix
HeartbeatTaskEditor.tsx
根目录 / desktop / frontend / src / custom / features / heartbeat / HeartbeatTaskEditor.tsx
1 import { useManagementT } from "../../../lib/managementLocale";
2 import { automationDraftDirty, useAutomationDraftStore } from "../../../store/automationDrafts";
3 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4 import { Check, ChevronsUpDown, CirclePause, Play, Trash2, X } from "lucide-react";
5 import { Tooltip } from "../../../components/Tooltip";
6 import { app } from "../../../lib/bridge";
7 import type { WorkspaceView } from "../../../lib/types";
8 import { CycleEditor } from "./HeartbeatCycleEditor";
9 import { CirclePlaySolid, mergeEngineRunState } from "./HeartbeatShared";
10 import { useHeartbeatT } from "./heartbeat.i18n";
11 import { changeHeartbeatFrequency, describeCron, formatCronNext, formatRelativeTime, isCronExpr, nextCronRunAt, type HeartbeatFrequencyType } from "./heartbeat.presentation";
12 import type { HeartbeatTask } from "./heartbeat.types";
13
14 function normalizeMode(mode: HeartbeatTask["approvalMode"]): "read-only" | "workspace-write" | "danger-full-access" {
15 if (mode === "read-only" || mode === "ask") return "read-only";
16 if (mode === "danger-full-access") return "danger-full-access";
17 return "workspace-write";
18 }
19
20 export function TaskEditor({
21 task,
22 onSave,
23 onDelete,
24 onCloseDetail,
25 onDirtyChange,
26 onOpenTopic,
27 onTrigger, onDiscard, onToggleEnabled, onSaveAsNew,
28 }: {
29 onDiscard?: () => void;
30 onToggleEnabled?: () => Promise<boolean>;
31 onSaveAsNew?: (task: HeartbeatTask) => Promise<boolean>;
32 task: HeartbeatTask;
33 onSave: (t: HeartbeatTask) => Promise<boolean>;
34 onDelete: () => Promise<boolean>;
35 onCloseDetail: () => void;
36 onDirtyChange?: (dirty: boolean) => void;
37 onOpenTopic?: (scope: string, workspaceRoot: string, topicId: string) => void;
38 onTrigger?: (id: string) => void;
39 }) {
40 const t = useHeartbeatT();
41 const m = useManagementT();
42 const entry = useAutomationDraftStore((state) => state.entries[task.id]);
43 const managed = Boolean(onDiscard);
44 const [localTab, setLocalTab] = useState<"configuration" | "history">("configuration");
45 const tab = managed ? entry?.tab ?? "configuration" : localTab;
46 const setTab = (value: "configuration" | "history") => managed ? useAutomationDraftStore.getState().ui(task.id, { tab: value }) : setLocalTab(value);
47 const titleRef = useRef<HTMLInputElement>(null);
48 const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
49 const [projectOpen, setProjectOpen] = useState(false);
50 const [confirmingDelete, setConfirmingDelete] = useState(false);
51 const [localSaving, setSaving] = useState(false);
52 const saving = localSaving || (managed && Boolean(entry?.busy));
53 const [saveError, setSaveError] = useState(false);
54 const [frequencyError, setFrequencyError] = useState(false);
55 const projectRef = useRef<HTMLDivElement>(null);
56
57 useEffect(() => {
58 app.ListWorkspaces().then((list) => setWorkspaces(list ?? [])).catch(() => {});
59 }, []);
60
61 useEffect(() => {
62 if (!projectOpen) return;
63 const close = (e: MouseEvent) => {
64 if (projectRef.current && !projectRef.current.contains(e.target as Node)) {
65 setProjectOpen(false);
66 }
67 };
68 document.addEventListener("click", close);
69 return () => document.removeEventListener("click", close);
70 }, [projectOpen]);
71
72 const [localDraft, setLocalDraft] = useState(task);
73 const draft = managed && entry ? entry.draft : localDraft;
74 const setDraft = useCallback((update: HeartbeatTask | ((task: HeartbeatTask) => HeartbeatTask)) => {
75 if (managed) useAutomationDraftStore.getState().edit(task.id, (current) => typeof update === "function" ? update(current) : update);
76 else setLocalDraft(update);
77 }, [managed, task.id]);
78 const initialTaskRef = useRef(task);
79 // 保存后父组件 setEditing({...task}) 传入新引用,同步基线使 isDirty
80 // 复位(保存按钮与 dirtyRef 不再保持脏状态)。
81 useEffect(() => {
82 initialTaskRef.current = task;
83 }, [task]);
84 // Sync fields owned outside this editor without replacing user-owned draft
85 // input. In particular, TriggerNow may advance topicId/lastRunAt/runHistory
86 // while the user is editing title, prompt, or schedule.
87 useEffect(() => {
88 if (managed) return;
89 setDraft((current) => mergeEngineRunState({ ...current, enabled: task.enabled }, task));
90 }, [managed, setDraft, task.enabled, task.lastRunAt, task.runHistory, task.topicId]);
91 const isNew = managed ? !entry?.baseline : !task.createdAt;
92 const isDirty = managed && entry ? automationDraftDirty(entry) : draft.title !== initialTaskRef.current.title
93 || draft.prompt !== initialTaskRef.current.prompt
94 || draft.interval !== initialTaskRef.current.interval
95 || draft.enabled !== initialTaskRef.current.enabled
96 || draft.approvalMode !== initialTaskRef.current.approvalMode
97 || draft.newConversationEachRun !== initialTaskRef.current.newConversationEachRun
98 || draft.notifyChannels !== initialTaskRef.current.notifyChannels
99 || draft.scope !== initialTaskRef.current.scope
100 || draft.workspaceRoot !== initialTaskRef.current.workspaceRoot
101 || draft.timeWindowStart !== initialTaskRef.current.timeWindowStart
102 || draft.timeWindowEnd !== initialTaskRef.current.timeWindowEnd;
103
104 useEffect(() => {
105 onDirtyChange?.(isDirty);
106 }, [isDirty, onDirtyChange]);
107
108 const promptRef = useRef<HTMLTextAreaElement>(null);
109
110 // Auto-grow prompt textarea: shrink-to-fit then cap at 180px
111 const autoGrowPrompt = useCallback(() => {
112 const el = promptRef.current;
113 if (!el) return;
114 el.style.height = "auto";
115 el.style.height = Math.min(el.scrollHeight, 180) + "px";
116 }, []);
117
118 useLayoutEffect(() => {
119 autoGrowPrompt();
120 }, [draft.prompt, autoGrowPrompt]);
121
122 // 手动保存(ChatGPT 式):修改后底部出现取消/保存,无修改时不显示。
123 // enabled 开关走头部即时保存并同步基线,不进入 isDirty。
124 const handleCancel = useCallback(() => {
125 setSaveError(false);
126 if (onDiscard) onDiscard(); else setDraft(initialTaskRef.current);
127 }, [onDiscard, setDraft]);
128
129 const handleSave = useCallback(async () => {
130 if (!draft.title.trim() || !draft.prompt.trim()) return;
131 setSaving(true);
132 const saved = await onSave(draft);
133 setSaving(false);
134 setSaveError(!saved);
135 }, [draft, onSave]);
136 const set = useCallback((field: keyof HeartbeatTask, value: string | boolean) => {
137 setDraft((prev) => ({ ...prev, [field]: value }));
138 }, [setDraft]);
139
140 // 启用/暂停切换(状态文字入口 + 右侧按钮共用):
141 // 只持久化 enabled 变更,基于最近保存基线(initialTaskRef)翻转,
142 // 不携带 draft 中尚未保存的 title/prompt/schedule 编辑;同时保留草稿,
143 // 等待中的用户输入不被基线快照覆盖。
144 const toggleEnabled = useCallback(async () => {
145 const saved = initialTaskRef.current;
146 const updated = { ...saved, enabled: !saved.enabled };
147 setSaving(true);
148 const persisted = onToggleEnabled ? await onToggleEnabled() : await onSave(updated);
149 setSaving(false);
150 setSaveError(!persisted);
151 if (persisted && !managed) {
152 // 只同步 enabled(磁盘已持久化),title/prompt/interval 等草稿编辑保留。
153 setDraft((prev) => ({ ...prev, enabled: updated.enabled }));
154 initialTaskRef.current = { ...saved, enabled: updated.enabled };
155 }
156 }, [managed, onSave, onToggleEnabled, setDraft]);
157
158 // Detect frequency type from interval value
159 const [localFrequency, setLocalFrequency] = useState<HeartbeatFrequencyType>(
160 (() => {
161 const iv = task.interval || "";
162 if (isCronExpr(iv)) return "cron";
163 const m = iv.match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)/);
164 if (m) return m[2] as "daily" | "weekly" | "biweekly" | "monthly" | "yearly";
165 return "interval";
166 })()
167 );
168
169 const freqType = managed ? entry?.frequency ?? localFrequency : localFrequency;
170 const setFreqType = useCallback((frequency: HeartbeatFrequencyType) => {
171 if (managed) useAutomationDraftStore.getState().ui(task.id, { frequency }); else setLocalFrequency(frequency);
172 }, [managed, task.id]);
173
174 // 切换频率类型时重建 interval(摊开的 7 个选项)
175 const onFreqSelect = useCallback((ft: HeartbeatFrequencyType) => {
176 const converted = changeHeartbeatFrequency(draft, ft);
177 if (converted === null) {
178 setFrequencyError(true);
179 return;
180 }
181 setFrequencyError(false);
182 setDraft(converted);
183 setFreqType(ft);
184 }, [draft, setDraft, setFreqType]);
185
186 const selectedWorkspace = draft.scope === "project" && draft.workspaceRoot
187 ? workspaces.find((w) => w.path === draft.workspaceRoot)
188 : null;
189
190 return (
191 <div className="heartbeat-editor">
192 {/* Header: 状态文字(点击切换,CodeX 式)+ 操作菜单 + 关闭 */}
193 <header className="heartbeat-editor__header">
194 {isNew ? (
195 <span className="heartbeat-editor__status heartbeat-editor__status--new">{t("heartbeat.newTask")}</span>
196 ) : (
197 <button
198 className={`heartbeat-editor__status${draft.enabled ? " heartbeat-editor__status--on" : ""}`}
199 type="button"
200 title={draft.enabled ? t("heartbeat.statusDisabled") : t("heartbeat.statusEnabled")}
201 disabled={saving}
202 onClick={() => void toggleEnabled()}
203 >
204 {draft.enabled ? t("heartbeat.statusEnabled") : t("heartbeat.statusDisabled")}
205 </button>
206 )}
207 <span className="heartbeat-editor__header-spacer" />
208 {!isNew && (
209 <Tooltip
210 label={t("heartbeat.runNow")}
211 side="top"
212 delay={60}
213 >
214 <button
215 className="heartbeat-editor__header-action"
216 type="button"
217 onClick={() => { if (onTrigger) onTrigger(task.id); }}
218 >
219 <Play size={14} strokeWidth={1.9} />
220 {t("heartbeat.runNow")}
221 </button>
222 </Tooltip>
223 )}
224 {!isNew && (
225 <Tooltip
226 label={draft.enabled ? t("heartbeat.clickPause") : t("heartbeat.clickStart")}
227 side="top"
228 delay={60}
229 >
230 <button
231 className={`heartbeat-editor__header-action${draft.enabled ? " heartbeat-editor__header-action--on" : ""}`}
232 type="button"
233 disabled={saving}
234 onClick={() => void toggleEnabled()}
235 >
236 {draft.enabled ? (
237 <CirclePause size={14} strokeWidth={2.4} />
238 ) : (
239 <CirclePlaySolid size={14} />
240 )}
241 {draft.enabled ? t("heartbeat.btnPause") : t("heartbeat.btnStart")}
242 </button>
243 </Tooltip>
244 )}
245 {!isNew && (
246 <Tooltip
247 label={confirmingDelete ? t("heartbeat.confirmDelete") : t("heartbeat.delete")}
248 side="top"
249 delay={60}
250 >
251 <button
252 className={`heartbeat-editor__header-action heartbeat-editor__header-action--danger${confirmingDelete ? " heartbeat-editor__header-action--confirm" : ""}`}
253 type="button"
254 disabled={saving}
255 onClick={() => {
256 if (managed || confirmingDelete) {
257 void onDelete();
258 } else {
259 setConfirmingDelete(true);
260 window.setTimeout(() => setConfirmingDelete(false), 3000);
261 }
262 }}
263 >
264 <Trash2 size={14} />
265 {confirmingDelete ? t("heartbeat.confirmDelete") : t("heartbeat.delete")}
266 </button>
267 </Tooltip>
268 )}
269 <button className="heartbeat-editor__close" type="button" onClick={onCloseDetail} title={t("common.close")}>
270 <X size={14} />
271 </button>
272 </header>
273
274 {/* Fields: 表单滚动区 */}
275 {isDirty && !isNew && <div className="management-notice">{m("savedRun")}</div>}
276 {managed && entry && (entry.missing || entry.conflicts.length > 0) && <div className="management-notice" role="alert">
277 {m(entry.missing ? "missingTask" : "conflict")}
278 {!entry.missing && <button className="btn btn--small" disabled={saving} onClick={handleCancel}>{m("reloadTask")}</button>}
279 <button className="btn btn--small" disabled={saving || !draft.title.trim() || !draft.prompt.trim()} onClick={() => void onSaveAsNew?.(draft)}>{m("saveAsNew")}</button>
280 </div>}
281 <div className="heartbeat-detail-tabs" role="tablist" aria-label={t("heartbeat.detailTitle")}>
282 <button role="tab" aria-selected={tab === "configuration"} onClick={() => setTab("configuration")}>{m("configuration")}</button>
283 <button role="tab" aria-selected={tab === "history"} onClick={() => setTab("history")}>{t("heartbeat.runHistory")}</button>
284 </div>
285 <div className="heartbeat-editor__fields">
286 <fieldset className="automation-fields" disabled={saving} hidden={tab !== "configuration"}>
287 {/* Title: 隐形输入框——无边框大标题样式,点击仍可直接编辑 */}
288 <input
289 ref={titleRef}
290 className="heartbeat-editor__title"
291 value={draft.title}
292 onChange={(e) => set("title", e.target.value)}
293 placeholder={t("heartbeat.titlePlaceholder")}
294 aria-label={t("heartbeat.fieldTitle")}
295 />
296
297 {/* Scope:仅新建任务时可选项目,保存后锁定(已创建任务不显示项目字段) */}
298 {isNew && (
299 <div className="heartbeat-editor__field">
300 <label>{t("heartbeat.scopeProject")}</label>
301 <div className="heartbeat-scope-wrap" ref={projectRef}>
302 <button
303 className="heartbeat-scope-select"
304 onClick={() => setProjectOpen((v) => !v)}
305 >
306 {selectedWorkspace ? selectedWorkspace.name : t("heartbeat.scopeGlobal")}
307 <ChevronsUpDown size={12} />
308 </button>
309 {projectOpen && (
310 <div className="heartbeat-project-menu">
311 {workspaces.length === 0 ? (
312 <div className="heartbeat-project-menu__empty">{t("heartbeat.noProjects")}</div>
313 ) : (
314 <>
315 <button
316 className={`heartbeat-project-menu__item${!draft.scope || draft.scope === "global" || !draft.workspaceRoot ? " heartbeat-project-menu__item--active" : ""}`}
317 onClick={() => {
318 setDraft((prev) => ({ ...prev, scope: "global", workspaceRoot: "" }));
319 setProjectOpen(false);
320 }}
321 >
322 {t("heartbeat.scopeGlobal")}
323 {(!draft.scope || draft.scope === "global" || !draft.workspaceRoot) && <Check size={12} className="heartbeat-filter-menu__check" />}
324 </button>
325 {workspaces.map((ws) => (
326 <button
327 key={ws.path}
328 className={`heartbeat-project-menu__item${draft.workspaceRoot === ws.path ? " heartbeat-project-menu__item--active" : ""}`}
329 onClick={() => {
330 setDraft((prev) => ({ ...prev, scope: "project", workspaceRoot: ws.path }));
331 setProjectOpen(false);
332 }}
333 >
334 {ws.name}
335 {ws.current && <span className="heartbeat-project-menu__current">{t("heartbeat.currentWorkspace")}</span>}
336 {draft.workspaceRoot === ws.path && <Check size={12} className="heartbeat-filter-menu__check" />}
337 </button>
338 ))}
339 </>
340 )}
341 </div>
342 )}
343 </div>
344 </div>
345 )}
346
347 {/* Prompt(无字段标题) */}
348 <div className="heartbeat-editor__field">
349 <textarea
350 className="heartbeat-editor__textarea"
351 value={draft.prompt}
352 onChange={(e) => set("prompt", e.target.value)}
353 placeholder={t("heartbeat.promptPlaceholder")}
354 rows={5}
355 />
356 </div>
357
358 {/* Approval Mode(竖排) */}
359 <div className="heartbeat-editor__field">
360 <label>{t("heartbeat.fieldApprovalMode")}</label>
361 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
362 <button
363 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "read-only" ? " set-seg__btn--on" : ""}`}
364 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "read-only" }))}
365 >
366 {t("heartbeat.permissionReadOnly")}
367 </button>
368 <button
369 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "workspace-write" ? " set-seg__btn--on" : ""}`}
370 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "workspace-write" }))}
371 >
372 {t("heartbeat.permissionWorkspaceWrite")}
373 </button>
374 <button
375 className={`set-seg__btn${normalizeMode(draft.approvalMode) === "danger-full-access" ? " set-seg__btn--on" : ""}`}
376 onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "danger-full-access" }))}
377 >
378 {t("heartbeat.permissionFullAccess")}
379 </button>
380 </div>
381 <span className="heartbeat-editor__mode-hint">
382 {normalizeMode(draft.approvalMode) === "danger-full-access" ? t("heartbeat.permissionFullAccessHint") :
383 normalizeMode(draft.approvalMode) === "workspace-write" ? t("heartbeat.permissionWorkspaceWriteHint") :
384 t("heartbeat.permissionReadOnlyHint")}
385 </span>
386 </div>
387
388 {/* Push to bot channels */}
389 <div className="heartbeat-editor__field">
390 <label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
391 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
392 <button
393 className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
394 onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
395 >
396 {t("heartbeat.notifyChannelsOn")}
397 </button>
398 <button
399 className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
400 onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
401 >
402 {t("heartbeat.notifyChannelsOff")}
403 </button>
404 </div>
405 <span className="heartbeat-editor__mode-hint">
406 {draft.notifyChannels === true
407 ? t("heartbeat.notifyChannelsOnHint")
408 : t("heartbeat.notifyChannelsOffHint")}
409 </span>
410 </div>
411
412 {/* New conversation per run */}
413 <div className="heartbeat-editor__field">
414 <label>{t("heartbeat.fieldNewConversation")}</label>
415 <div className="set-seg" style={{ alignSelf: "flex-start" }}>
416 <button
417 className={`set-seg__btn${!draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
418 onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: false }))}
419 >
420 {t("heartbeat.newConversationEachRunOff")}
421 </button>
422 <button
423 className={`set-seg__btn${draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
424 onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: true }))}
425 >
426 {t("heartbeat.newConversationEachRunOn")}
427 </button>
428 </div>
429 </div>
430
431 {/* Frequency */}
432 <div className="heartbeat-editor__field">
433 <label>{t("heartbeat.fieldInterval")}</label>
434 <div className="set-seg" style={{ alignSelf: "flex-start", flexWrap: "wrap" }}>
435 {([
436 ["interval", t("heartbeat.freqInterval")],
437 ["daily", t("heartbeat.cycleDaily")],
438 ["weekly", t("heartbeat.cycleWeekly")],
439 ["biweekly", t("heartbeat.cycleBiweekly")],
440 ["monthly", t("heartbeat.cycleMonthly")],
441 ["yearly", t("heartbeat.cycleYearly")],
442 ["cron", t("heartbeat.freqCron")],
443 ] as const).map(([v, label]) => (
444 <button
445 key={v}
446 type="button"
447 className={`set-seg__btn${freqType === v ? " set-seg__btn--on" : ""}`}
448 onClick={() => onFreqSelect(v)}
449 >
450 {label}
451 </button>
452 ))}
453 </div>
454 {frequencyError && (
455 <span className="heartbeat-editor__inline-error" role="status">{t("heartbeat.frequencyConversionFailed")}</span>
456 )}
457
458 {freqType === "cron" ? (
459 <div className="heartbeat-editor__freq-interval">
460 <input
461 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--cron"
462 value={draft.interval}
463 onChange={(e) => setDraft((prev) => ({ ...prev, interval: e.target.value }))}
464 placeholder={t("heartbeat.cronPlaceholder")}
465 />
466 <span className="heartbeat-editor__cron-hint">
467 {describeCron(draft.interval, t)}
468 {nextCronRunAt(draft.interval) ? ` ${t("heartbeat.cronNextRun")} ${formatCronNext(nextCronRunAt(draft.interval))}` : ""}
469 </span>
470 </div>
471 ) : freqType === "interval" ? (
472 <div className="heartbeat-editor__freq-interval">
473 <span className="heartbeat-editor__freq-label">{t("heartbeat.freqEvery")}</span>
474 <input
475 className="heartbeat-editor__freq-input"
476 value={(() => {
477 const m = (draft.interval || "").match(/^(\d+)/);
478 return m ? m[1] : "";
479 })()}
480 onChange={(e) => {
481 const num = e.target.value.replace(/\D/g, "");
482 const unit = (draft.interval || "").match(/([smh])$/)?.[1] || "h";
483 setDraft((prev) => ({ ...prev, interval: num + unit }));
484 }}
485 placeholder="1"
486 />
487 <div className="set-seg">
488 <button
489 className={`set-seg__btn${(() => {
490 const m = (draft.interval || "").match(/^(\d+)([smh])/);
491 return (m ? m[2] : "h") === "m" ? " set-seg__btn--on" : "";
492 })()}`}
493 onClick={() => {
494 const num = (draft.interval || "").match(/^(\d+)/)?.[1] || "1";
495 setDraft((prev) => ({ ...prev, interval: num + "m" }));
496 }}
497 >
498 {t("heartbeat.unitMin")}
499 </button>
500 <button
501 className={`set-seg__btn${(() => {
502 const m = (draft.interval || "").match(/^(\d+)([smh])/);
503 return (m ? m[2] : "h") === "h" ? " set-seg__btn--on" : "";
504 })()}`}
505 onClick={() => {
506 const num = (draft.interval || "").match(/^(\d+)/)?.[1] || "1";
507 setDraft((prev) => ({ ...prev, interval: num + "h" }));
508 }}
509 >
510 {t("heartbeat.unitHour")}
511 </button>
512 </div>
513 {draft.timeWindowStart || draft.timeWindowEnd ? (
514 <div className="heartbeat-editor__tw-inputs" style={{ marginLeft: "8px" }}>
515 <input
516 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
517 type="time"
518 value={draft.timeWindowStart || ""}
519 onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowStart: e.target.value || undefined }))}
520 style={{ width: "90px" }}
521 />
522 <span className="heartbeat-editor__freq-label heartbeat-editor__tw-sep">—</span>
523 <input
524 className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
525 type="time"
526 value={draft.timeWindowEnd || ""}
527 onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowEnd: e.target.value || undefined }))}
528 style={{ width: "90px" }}
529 />
530 <button
531 className="heartbeat-editor__tw-remove"
532 onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: undefined, timeWindowEnd: undefined }))}
533 title={t("heartbeat.removeTimeWindow")}
534 >
535 <X size={12} />
536 </button>
537 </div>
538 ) : (
539 <span className="heartbeat-editor__tw-add" style={{ marginLeft: "8px" }}
540 onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: "09:00", timeWindowEnd: "17:00" }))}
541 >
542 + {t("heartbeat.timeWindow")}
543 </span>
544 )}
545 </div>
546 ) : (
547 <CycleEditor
548 key={freqType}
549 draft={draft}
550 setDraft={set}
551 cycleType={freqType as "daily" | "weekly" | "biweekly" | "monthly" | "yearly"}
552 />
553 )}
554 </div>
555
556 {/* 运行历史记录:每次成功执行的记录,点击可打开对应对话
557 历史为空但有最近会话(task.topicId)时,用最近会话合成一条——旧任务
558 在 runHistory 字段引入前执行过,topicId 仍指向最近对话 */}
559 </fieldset>
560 <div className="heartbeat-run-history" hidden={tab !== "history"}>
561 <div className="heartbeat-run-history__header">
562 <span>{t("heartbeat.runHistory")}</span>
563 </div>
564 {(() => {
565 const history = (task.runHistory || []).length > 0
566 ? [...task.runHistory!].reverse()
567 : task.topicId
568 ? [{ at: task.lastRunAt || task.createdAt || Date.now(), topicId: task.topicId }]
569 : [];
570 if (history.length === 0) {
571 return <div className="heartbeat-run-history__empty">{t("heartbeat.runHistoryEmpty")}</div>;
572 }
573 return (
574 <div className="heartbeat-run-history__list">
575 {history.map((run, i) => (
576 <button
577 key={`${run.at}-${i}`}
578 className="heartbeat-run-history__item"
579 type="button"
580 disabled={!run.topicId}
581 onClick={() => {
582 if (run.topicId && onOpenTopic) {
583 onOpenTopic(task.scope || "global", task.workspaceRoot || "", run.topicId);
584 }
585 }}
586 title={run.topicId ? t("heartbeat.openTopic") : ""}
587 >
588 <span className="heartbeat-run-history__title">{task.title || t("heartbeat.untitled")}</span>
589 <span className="heartbeat-run-history__scope">
590 {task.scope === "project" && task.workspaceRoot
591 ? (workspaces.find((w) => w.path === task.workspaceRoot)?.name
592 || task.workspaceRoot.split("/").pop() || task.workspaceRoot)
593 : t("heartbeat.scopeGlobal")}
594 </span>
595 <span className="heartbeat-run-history__rel">{formatRelativeTime(run.at, Date.now(), t)}</span>
596 {!run.topicId && (
597 <span className="heartbeat-run-history__notopic">{t("heartbeat.runHistoryNoTopic")}</span>
598 )}
599 </button>
600 ))}
601 </div>
602 );
603 })()}
604 </div>
605 </div>
606
607 {/* 保存/取消:仅在有未保存修改时显示(ChatGPT 式),固定在面板底部 */}
608 {(saveError || (managed && entry?.error)) && (
609 <div className="heartbeat-editor__save-notice">
610 <span className="heartbeat-editor__save-error" role="alert">{t("heartbeat.saveFailed")}</span>
611 </div>
612 )}
613 {(managed || isNew || isDirty) && (
614 <div className="heartbeat-editor__actions">
615 {managed && <span className="automation-save-status" role="status">{m(saving ? "saving" : isDirty ? "unsaved" : "saved")}</span>}
616 <button
617 className="heartbeat-editor__action-btn"
618 type="button"
619 disabled={saving || (!isNew && !isDirty)}
620 onClick={handleCancel}
621 >
622 {managed ? m("discard") : t("common.cancel")}
623 </button>
624 <button
625 className="heartbeat-editor__action-btn heartbeat-editor__action-btn--primary"
626 type="button"
627 disabled={saving || (!isNew && !isDirty) || Boolean(managed && (entry?.missing || entry?.conflicts.length)) || !draft.title.trim() || !draft.prompt.trim()}
628 onClick={() => void handleSave()}
629 >
630 {t("common.save")}
631 </button>
632 </div>
633 )}
634 </div>
635 );
636 }
637
637 lines Plain Text