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